فاز 0 · درس 13

پایتون برای مهندسی هوش مصنوعی: الگوها، تله‌ها و بهینه‌سازی

نوع
ساختنی
زمان
حدود 120 دقیقه
زبان‌ها
Python
پیش‌نیاز
محیط توسعه، محیط‌های پایتون
تصویر مفهومی مقایسه‌ی سرعت پردازش؛ یک ساختار برداری پیوسته فلزی مدرن در کنار زنجیره‌ای از حلقه‌ها و اشاره‌گرها روی یک سطح تیره مهندسی
فهرست مطالب
  1. اهداف یادگیری
  2. صورت مسئله
  3. مفاهیم پایه
  4. کدهای دوره از چه ساختارهایی استفاده می‌کنند
  5. نام‌ها به اشیاء اشاره می‌کنند
  6. لیست اشاره‌گرها را نگه می‌دارد، آرایه اعداد را
  7. لیست تمام آیتم‌ها را می‌سازد، مولد یکی‌یکی تحویل می‌دهد
  8. الگوهای کاربردی و ضروری
  9. 1. مقادیر، مجموعه‌ها و آنپک‌کردن
  10. 2. کامپریهنشن‌ها
  11. 3. توابع، آرگومان‌ها و کلوژرها
  12. 4. کلاس‌ها و دیتاکلاس‌ها
  13. 5. تکرارکننده‌ها و مولدها
  14. 6. کانتکست منیجرها و استثناها
  15. 7. تایپ هینت‌ها، ماژول‌ها و گارد main
  16. 8. فایل‌ها، JSON ،CSV و مرتب‌سازی
  17. 9. شمارش، گروه‌بندی و ابزارهای تکرارکننده
  18. 10. چرا حلقه‌ها روی اعداد کند هستند
  19. پیاده‌سازی از صفر
  20. گام 1: تعریف ساختار رکورد و بارگذار دیتاست
  21. گام 2: تقسیم داده‌های آموزش و تست با دانه‌ی تصادفی
  22. گام 3: مولد مینی‌بچ
  23. گام 4: محاسبات برداری و استانداردساز
  24. گام 5: اجرای کامل برنامه
  25. گام 6: سنجش سرعت و مصرف حافظه
  26. استفاده از ابزارهای واقعی
  27. معادل‌های کتابخانه‌ی استاندارد
  28. محاسبات یکسان با آرایه‌های کامپایل‌شده
  29. پخش (Broadcasting)
  30. خروجی این درس
  31. تمرین‌ها
  32. راهنمایی‌ها
  33. اصطلاحات کلیدی
  34. برای مطالعه‌ی بیشتر

بسیاری از توسعه‌دهندگانی که وارد حوزه‌ی هوش مصنوعی و یادگیری ماشین می‌شوند، به جای تمرکز روی منطق الگوریتم‌ها و فرمول‌های ریاضی، ساعت‌ها درگیر رفتارهای ناخواسته و قواعد نحوی پایتون می‌شوند. ساختارهایی مانند انتساب نام‌ها به‌جای کپی اشیاء، آرگومان‌های پیش‌فرض تغییرپذیر و مصرف‌شدن یک‌باره‌ی مولدها از جمله تله‌هایی هستند که بدون هیچ پیام خطایی، نتایج اشتباهی در محاسبات و فرآیند آموزش مدل پدید می‌آورند.

این درس الگوهای کلیدی و پرکاربرد پایتون استاندارد — از کامپریهنشن‌ها و دیتاکلاس‌ها گرفته تا کار با تکرارکننده‌ها و ابزارهای ساخت مینی‌بچ — را مرور می‌کند و تفاوت معماری داده در مفسر CPython و آرایه‌های کامپایل‌شده‌ی NumPy را زیر ذره‌بین می‌گذارد تا بدانید چرا یک حلقه ساده پایتون می‌تواند فرآیند پردازش داده را به‌شدت کند کند.

برای اجرای کدهای پایه و پیاده‌سازی‌های این درس به هیچ سرویس ابری یا کارت گرافیک نیاز ندارید؛ همه چیز روی پردازنده‌ی استاندارد سیستم شخصی شما با پایتون نسخه 3.11 به بالا قابل اجراست. همچنین در صورت نیاز به نصب بسته‌های جانبی نظیر NumPy، ابزارهایی مانند uv در محیط‌های ایزوله روند را ساده و سریع می‌کنند.

بیشتر کدهای این دوره از مجموعه‌ی کوچک و یکسانی از الگوهای رایج (idioms) پایتون استفاده می‌کنند. این الگوها را همین‌جا یاد بگیرید، پیش از آنکه درس‌های ریاضی و مدل‌سازی به آن‌ها وابسته شوند.

اهداف یادگیری

  • خواندن و نوشتن الگوهایی که بیشتر کدهای دوره به کار می‌برند؛ از آنپک‌کردن (unpacking) و f-stringها گرفته تا دیتاکلاس‌ها (dataclasses) و کامپریهنشن‌ها (comprehensions).
  • شناسایی سه خطای پایتونی که بدون هیچ پیامی خروجی نادرست می‌دهند: لیست‌های اشتراکی، مقادیر پیش‌فرض تغییرپذیر، و مولدهای مصرف‌شده.
  • ساخت بارگذار CSV و JSON، تقسیم داده با دانه‌ی تصادفی (seeded split)، و مولد مینی‌بچ (mini-batch) تنها با استفاده از کتابخانه‌ی استاندارد.
  • پیاده‌سازی ضرب داخلی، میانگین و انحراف معیار در پایتون خالص، و سنجش سرعت و مصرف حافظه‌ی آن‌ها.
  • توضیح این موضوع که چرا حلقه‌ی پایتون خالص روی اعداد کند است، و پخش (broadcasting) در NumPy چگونه این حلقه را به کدهای کامپایل‌شده منتقل می‌کند.

صورت مسئله

درس‌های پس از این فاز، برای پیاده‌سازی ریاضیات و مدل‌ها از پایتون ساده و خالص استفاده می‌کنند. از 541 فایل پایتون در این درس‌ها، 276 فایل ماژول dataclasses را وارد می‌کنند. همچنین 187 فایل متد random.seed را صدا می‌زنند یا یک مولد random.Random می‌سازند. اگر این الگوها برایتان تازه باشند، به‌جای تمرکز روی منطق ریاضی، مدام درگیر فهم ساختار نحوی کد می‌شوید.

پایتون رفتارهایی هم دارد که بدون نمایش هیچ پیام خطایی، نتیجه‌ی نادرست تولید می‌کنند. دو نام مختلف می‌توانند به یک لیست اشاره کنند؛ بنابراین تغییر داده از طریق یکی از آن‌ها، دیگری را هم تغییر می‌دهد. یک آرگومان پیش‌فرض، مقدارش را از یک فراخوانی به فراخوانی بعدی حفظ می‌کند. یک مولد پس از یک بار پیمایش خالی می‌شود؛ در نتیجه در دور (epoch) دوم آموزش هیچ داده‌ای دریافت نمی‌کند و مقدار تابع زیان (loss) تغییری نخواهد کرد.

مسئله‌ی آخر سرعت است. روی لپ‌تاپ آزمایشی ما، یک حلقه‌ی پایتون خالص روی یک میلیون عدد اعشاری حدود 30 میلی‌ثانیه زمان برد. NumPy همین ضرب داخلی را در کمتر از یک میلی‌ثانیه انجام داد. باید علت این تفاوت را بدانید تا بتوانید آگاهانه بین یک حلقه‌ی سرراست و یک عملیات آرایه‌ای دست به انتخاب بزنید.

مفاهیم پایه

کدهای دوره از چه ساختارهایی استفاده می‌کنند

تمام 541 فایل پایتون موجود در مسیر phases/*/*/code/ را با ماژول ast تحلیل کردیم. جدول زیر تعداد فایل‌هایی را نشان می‌دهد که حداقل یک بار از هر ساختار استفاده کرده‌اند. این آمار مربوط به زمان نگارش همین درس است و با افزوده‌شدن درس‌های جدید تغییر می‌کند.

ساختار تعداد فایل‌ها سهم
تعریف تابع 541 100%
if __name__ == "__main__": 529 98%
رشته‌های f-string 517 96%
باز کردن تاپل (Tuple unpacking) 448 83%
کلاس‌ها 379 70%
کامپریهنشن لیست (List comprehensions) 375 69%
تایپ هینت روی توابع 342 63%
عبارات مولد (Generator expressions) 326 60%
برش‌ها (Slices) 314 58%
توابع lambda 175 32%
دستورهای try و except 120 22%
دستورهای with 96 18%
کامپریهنشن دیکشنری (Dict comprehensions) 93 17%
توابع مولد با yield 14 3%

بررسی دستورهای import نشان می‌دهد کدام ماژول‌های استاندارد اهمیت بیشتری دارند. از بین 541 فایل، 340 فایل با عبارت from __future__ import annotations شروع می‌شوند و 276 فایل ماژول dataclasses را وارد می‌کنند. ماژول‌های math، random و json به ترتیب در 187، 175 و 126 فایل حضور دارند. ماژول collections در 70 فایل دیده می‌شود و itertools فقط در 4 فایل به کار رفته است.

هیچ درسی متدهای map() یا filter() را صدا نمی‌زند؛ کامپریهنشن‌ها این کار را انجام می‌دهند. فراخوانی‌های sorted، min، max یا .sort به همراه تابع key= در بیش از 140 فایل دیده می‌شوند. این درس بیشترین زمان را به الگوهای صدر این فهرست‌ها اختصاص می‌دهد.

نام‌ها به اشیاء اشاره می‌کنند

در پایتون، متغیر نامی است که به یک شیء اشاره دارد. انتساب (assignment) یک نام را به یک شیء پیوند می‌دهد، نه اینکه آن شیء را کپی کند. وقتی دو نام به یک لیست اشاره می‌کنند، هر تغییری از طریق هر یک از آن‌ها، در دیگری نیز دیده می‌شود. اعداد، رشته‌ها و تاپل‌ها تغییرناپذیرند؛ به همین دلیل این قاعده مشکلی برایشان پیش نمی‌آورد. اما لیست‌ها، دیکشنری‌ها و مجموعه‌ها تغییرپذیرند، پس وقتی به نسخه‌ای مستقل نیاز دارید، باید یک کپی از آن‌ها بسازید.

لیست اشاره‌گرها را نگه می‌دارد، آرایه اعداد را

لیست پایتون اعداد را مستقیماً در خود ذخیره نمی‌کند. لیست اشاره‌گرها را ذخیره می‌کند و هر اشاره‌گر به یک شیء کامل پایتون اشاره دارد. در یک نسخه‌ی 64 بیتی از CPython، هر اشاره‌گر 8 بایت و هر شیء float معادل 24 بایت فضا می‌گیرد. در مقابل، یک آرایه‌ی float64 در NumPy مقادیر خام 8 بایتی را کنار یکدیگر در یک بلوک پیوسته از حافظه نگه می‌دارد.

آرایه NumPy float64 با 3 مقدارلیست پایتون شامل 3 عدد اعشاریشیء لیست3 اشاره‌گر، هر کدام 8 بایتشیء اعشاری24 بایتشیء اعشاری24 بایتشیء اعشاری24 بایتیک بلوک داده3 مقدار، هر کدام 8 بایت

یک حلقه روی لیست، برای هر عنصر کار بسیار زیادی انجام می‌دهد. مفسر دستورهای بایت‌کد را یکی‌یکی اجرا می‌کند. برای هر عنصر، اشاره‌گر را دنبال کرده و نوع هر عملوند را بررسی می‌کند. سپس کدهای ضرب ممیز شناور را فراخوانی کرده و یک شیء اعشاری پایتون می‌سازد تا نتیجه را در آن ذخیره کند.

اما NumPy همین محاسبات را در قالب یک حلقه‌ی کامپایل‌شده روی مقادیر خام انجام می‌دهد. نوع داده را فقط یک بار برای کل آرایه بررسی می‌کند و برای تک‌تک عناصر هیچ شیء پایتونی نمی‌سازد. کدهای برداری‌شده (vectorized) یک عملیات را روی کل آرایه توصیف می‌کنند؛ مثل (x - mean) / std، و اجرای حلقه بر عهده‌ی کتابخانه خواهد بود. توابع برداری در این درس همگی از همین قاعده پیروی می‌کنند. هر کدام یک عملیات آرایه‌ای انجام می‌دهند تا بعداً بتوانید آن‌ها را با یک فراخوانی در NumPy جایگزین کنید.

لیست تمام آیتم‌ها را می‌سازد، مولد یکی‌یکی تحویل می‌دهد

یک کامپریهنشن لیست، تمام آیتم‌ها را پیش از اجرای خط بعد می‌سازد. در مقابل، یک مولد (generator) هر آیتم را همان لحظه‌ای تولید می‌کند که فراخواننده آیتم بعدی را درخواست دهد. حافظه‌ی مورد نیاز یک لیست با افزایش تعداد آیتم‌ها رشد می‌کند، اما حافظه‌ی یک مولد برای هر تعداد داده‌ای تقریباً ثابت می‌ماند.

یک مولد را فقط یک بار می‌توان اجرا کرد. پس از اتمام آخرین آیتم، با هر درخواست خطای StopIteration صادر می‌کند؛ بنابراین دومین حلقه‌ی for روی آن هیچ آیتمی دریافت نخواهد کرد. از آنجا که کدهای آموزش در هر دور داده‌ها را یک بار کامل می‌خوانند، برای هر دور باید مولد جدیدی بسازید.

توابع مولد در کدهای این درس‌ها کم‌تعدادند، اما بارگذارهای داده‌ای که در درس‌های یادگیری عمیق استفاده می‌شوند در واقع تکرارپذیرهایی (iterables) از بچ‌ها هستند؛ بنابراین این ایده در تک‌تک حلقه‌های آموزش وجود دارد. تصویر زیر هر دو رویکرد را روی همان هشت سطر نشان می‌دهد.

لیست در برابر مولدلیست همه‌ی سطرها را نگه می‌دارد، مولد فقط یک دسته را
LIST VS GENERATORBoth loops read the same eight rows. The list version reads every row into memory before the first training step starts. The generator version holds one batch of two rows, and the first step starts when that batch is ready. For a real dataset, the list needs memory for the whole file, and the generator needs memory for one batch.داده روی دیسک، 8 سطردر حافظهrows = list(read_rows(path))8 از 8 سطر در حافظهگام 1 منتظر بارگذاری همه استfor batch in batches(read_rows(path), 2):2 از 8 سطر در حافظهگام 1 با دسته‌ی 1 شروع می‌شودسطرها و دسته‌های یکسان، حافظه‌ی مصرفی متفاوت
هر دو حلقه همان هشت سطر را می‌خوانند. نسخه‌ی مبتنی بر لیست قبل از شروع نخستین گام آموزش، تمام سطرها را در حافظه بارگذاری می‌کند. نسخه‌ی مبتنی بر مولد (ژنراتور) تنها یک دسته‌ی دوسطری را نگه می‌دارد و گام نخست به محض آماده شدن همین دسته شروع می‌شود. برای یک مجموعه‌داده‌ی واقعی، لیست به اندازه‌ی کل فایل حافظه می‌خواهد، اما مولد فقط به اندازه‌ی یک دسته نیاز به حافظه دارد.

الگوهای کاربردی و ضروری

هر کدام از بلوک‌های این بخش مستقل اجرا می‌شوند. آن‌ها را در یک فایل ذخیره کرده و با python3 اجرا کنید. اجرای این بلوک‌ها به Python 3.11 یا بالاتر نیاز دارد؛ یعنی همان نسخه‌ای که در درس محیط توسعه نصب کردیم.

1. مقادیر، مجموعه‌ها و آنپک‌کردن

پایتون چهار مجموعه‌ی توکار (built-in collections) دارد. زمان استفاده از هر کدام را یاد بگیرید.

نوع مثال ترتیب‌دار تغییرپذیر کاربرد رایج در دوره
list [0.5, 1.2] بله بله نمونه‌ها، مقادیر تابع زیان، وزن‌ها
tuple (32, 784) بله خیر ابعاد، رکوردهای ثابت، کلیدهای دیکشنری
dict {"lr": 0.01} بر اساس ترتیب درج بله تنظیمات، واژگان (vocabularies)، سطرهای JSON
set {"cat", "dog"} خیر بله توکن‌های یکتا، بررسی اشتراک

این بلوک کد نشان می‌دهد که انتساب، لیست را کپی نمی‌کند.

weights = [0.5, -1.2, 3.0]
alias = weights
alias[0] = 99.0
print(weights)

independent = list(weights)
independent[1] = 0.0
print(weights, independent)

grid = [[0] * 3] * 2
grid[0][0] = 1
print(grid)

grid = [[0] * 3 for _ in range(2)]
grid[0][0] = 1
print(grid)
[99.0, -1.2, 3.0]
[99.0, -1.2, 3.0] [99.0, 0.0, 3.0]
[[1, 0, 0], [1, 0, 0]]
[[1, 0, 0], [0, 0, 0]]

عبارت [[0] * 3] * 2 یک لیست بیرونی با دو ارجاع به یک لیست درونی یکسان می‌سازد. در مقابل، کامپریهنشن برای هر سطر یک لیست درونی جدید ایجاد می‌کند. در فاز ریاضیات ماتریس‌ها را با لیست می‌سازیم، پس از شیوه‌ی کامپریهنشن استفاده کنید.

آنپک‌کردن، اعضای یک دنباله را در یک خط به چندین نام نسبت می‌دهد. اگر از علامت ستاره در کنار نام متغیر استفاده کنید، باقی‌مانده‌ی آیتم‌ها در آن جمع می‌شوند. رشته‌های f-string نیز مقدار یک عبارت را داخل رشته قرار می‌دهند و بعد از علامت دو‌نقطه می‌توان فرمت دلخواه را هم مشخص کرد.

shape = (32, 784)
batch_size, n_features = shape
first, *middle, last = [1, 2, 3, 4, 5]
x, y = 1.0, 3.0
x, y = y, x

config = {"lr": 0.01, "epochs": 5}
config["seed"] = 7
tokens = ["the", "cat", "sat", "on", "the", "mat"]
vocab = set(tokens)

print(batch_size, n_features, first, middle, last, x, y)
print(config.get("momentum", 0.9), "cat" in vocab, len(vocab))
print(f"lr={config['lr']:.0e} loss={0.123456:.3f} n={60_000:,} {batch_size=}")
32 784 1 [2, 3, 4] 5 3.0 1.0
0.9 True 5
lr=1e-02 loss=0.123 n=60,000 batch_size=32

متد dict.get در صورت نبودن کلید، مقدار پیش‌فرض را برمی‌گرداند. بررسی in روی یک مجموعه (set)، برای هر اندازه‌ای از مجموعه در زمان تقریباً ثابتی اجرا می‌شود. ساختار {batch_size=} نام و مقدار متغیر را با هم چاپ می‌کند که هنگام دیباگ‌کردن بسیار کمک‌کننده است.

در برش a[start:stop:step]، اندیس start لحاظ می‌شود اما stop خارج از بازه می‌ماند. اندیس‌های منفی از انتهای دنباله می‌شمارند. برشِ یک لیست همیشه یک لیست جدید است، بنابراین برش زدن یک لیست بزرگ، تمام اشاره‌گرهای آن را کپی خواهد کرد.

tokens = ["<s>", "the", "cat", "sat", "on", "the", "mat", "</s>"]
print(tokens[1:-1])
print(tokens[:3], tokens[-3:])
print(tokens[::2])
print(tokens[::-1][:2])
windows = [tokens[i:i + 3] for i in range(len(tokens) - 2)]
print(len(windows), windows[0])
['the', 'cat', 'sat', 'on', 'the', 'mat']
['<s>', 'the', 'cat'] ['the', 'mat', '</s>']
['<s>', 'cat', 'on', 'mat']
['</s>', 'mat']
6 ['<s>', 'the', 'cat']

2. کامپریهنشن‌ها

یک کامپریهنشن، یک لیست، دیکشنری یا مجموعه را از روی یک تکرارپذیر در قالب یک عبارت می‌سازد. ظاهر آن دقیقاً شبیه همان ریاضیاتی است که توصیفش می‌کند.

scores = [0.91, 0.42, 0.77, 0.15, 0.66]
labels = [1 if s >= 0.5 else 0 for s in scores]
high = [s for s in scores if s > 0.6]
squares = {i: i * i for i in range(4)}
lengths = {len(word) for word in ["a", "bb", "cc", "ddd"]}
sum_sq = sum(s * s for s in scores)

matrix = [[1, 2, 3], [4, 5, 6]]
transposed = [list(column) for column in zip(*matrix)]
flat = [v for row in matrix for v in row]

print(labels, high, squares, lengths, round(sum_sq, 4))
print(transposed, flat)
[1, 0, 1, 0, 1] [0.91, 0.77, 0.66] {0: 0, 1: 1, 2: 4, 3: 9} {1, 2, 3} 2.0555
[[1, 4], [2, 5], [3, 6]] [1, 2, 3, 4, 5, 6]

یک شرط if در انتها، فقط آیتم‌های خاصی را نگه می‌دارد. عبارتی که پیش از for می‌آید می‌تواند یک عبارت شرطی مثل a if condition else b باشد. دو عبارت for نیز دقیقاً مانند دو حلقه‌ی تودرتو به همان ترتیب اجرا می‌شوند.

پرانتزهای گرد، عبارت مولد (generator expression) می‌سازند. در عبارت sum(s * s for s in scores)، تابع sum توان‌های دو را یکی‌یکی می‌خواند و پایتون هیچ لیستی نمی‌سازد. در عبارت zip(*matrix) نیز ستاره هر سطر را به‌عنوان یک آرگومان جداگانه ارسال می‌کند و zip آیتم‌های هم‌موقعیت را جفت‌وجور می‌کند.

3. توابع، آرگومان‌ها و کلوژرها

یک مقدار پیش‌فرض، آرگومان را اختیاری می‌کند. یک آرگومان کلیدواژه‌ای (keyword argument)، پارامتر را هنگام فراخوانی نام‌گذاری می‌کند. قرار دادن یک علامت * به‌تنهایی در فهرست پارامترها، پارامترهای بعدی را فقط-کلیدواژه‌ای (keyword-only) می‌کند.

def clip(value, low=0.0, high=1.0):
    return max(low, min(high, value))


def train(data, *, lr=0.01, epochs=3):
    return f"{len(data)} samples, lr={lr}, epochs={epochs}"


def make_scaler(factor):
    def scale(values):
        return [v * factor for v in values]
    return scale


print(clip(1.7), clip(-3.0, low=-1.0), clip(0.4, high=0.3))
print(train([1, 2, 3], lr=0.1))
to_percent = make_scaler(100)
print(to_percent([0.25, 0.5]))
1.0 -1.0 0.3
3 samples, lr=0.1, epochs=3
[25.0, 50.0]

با آرگومان‌های فقط-کلیدواژه‌ای، فراخوانی به شکل train(data, 0.1, 3) خطای TypeError می‌دهد. فراخواننده حتماً باید بنویسد lr=0.1 تا جای دو عدد به اشتباه با یکدیگر عوض نشود.

تابع درونی scale از متغیر factor متعلق به تابعی که آن را ساخته استفاده می‌کند. پایتون تا زمانی که scale وجود دارد factor را نگه می‌دارد. تابعی که متغیرهای سازنده‌اش را حفظ می‌کند، کلوژر (closure) نامیده می‌شود. استانداردساز در بخش پیاده‌سازی از صفر، برای نگهداری آماره‌های آموزش از یک کلوژر استفاده می‌کند.

عبارت lambda یک تابع تک‌عبارتی و بی‌نام است. کدهای این دوره معمولاً از آن به عنوان یک تابع برای آرگومان key= استفاده می‌کنند، که در بخش 8 نشان داده شده است.

پایتون مقدار پیش‌فرض را فقط یک بار و در زمان اجرای دستور def محاسبه می‌کند. هر فراخوانی که از مقدار پیش‌فرض استفاده کند، دقیقاً همان شیء قبلی را تحویل می‌گیرد.

def add_bad(item, bucket=[]):
    bucket.append(item)
    return bucket


def add_good(item, bucket=None):
    if bucket is None:
        bucket = []
    bucket.append(item)
    return bucket


add_bad("a")
print(add_bad("b"))
add_good("a")
print(add_good("b"))
['a', 'b']
['b']

از None به عنوان مقدار پیش‌فرض برای لیست، دیکشنری یا مجموعه استفاده کنید و شیء جدید را داخل خود تابع بسازید.

4. کلاس‌ها و دیتاکلاس‌ها

دکوراتور @dataclass متدهای __init__، __repr__ و __eq__ را بر اساس فیلدهای مشخص‌شده با تایپ هینت می‌نویسد. با frozen=True، شیء پس از ساخت تغییرناپذیر خواهد بود و می‌توانید از آن به‌عنوان کلید دیکشنری استفاده کنید یا آن را درون مجموعه بگذارید.

from dataclasses import dataclass, field


@dataclass(frozen=True)
class Example:
    features: tuple[float, ...]
    label: int


@dataclass
class TrainConfig:
    lr: float = 0.01
    epochs: int = 5
    hidden: list[int] = field(default_factory=lambda: [64, 32])

    def describe(self):
        return f"lr={self.lr} epochs={self.epochs} hidden={self.hidden}"


ex = Example((1.5, 2.0), 1)
print(ex)
print(ex == Example((1.5, 2.0), 1), len({ex, Example((1.5, 2.0), 1)}))
print(TrainConfig(epochs=10).describe())
try:
    ex.label = 0
except AttributeError as err:
    print(type(err).__name__)
Example(features=(1.5, 2.0), label=1)
True 1
lr=0.01 epochs=10 hidden=[64, 32]
FrozenInstanceError

عبارت field(default_factory=...) به هر نمونه یک لیست جدید می‌دهد. این کار مشکل پیش‌فرض مشترک در بخش 3 را در یک دیتاکلاس حل می‌کند.

متدهای خاص (special methods)، کلاس را به قواعد نحوی پایتون متصل می‌کنند. برای مثال، دستور len(ds) متد __len__ را صدا می‌زند و ds[2] متد __getitem__ را فراخوانی می‌کند.

class SquaresDataset:
    def __init__(self, n):
        self.n = n

    def __len__(self):
        return self.n

    def __getitem__(self, index):
        if not 0 <= index < self.n:
            raise IndexError(index)
        return index, index * index


ds = SquaresDataset(4)
print(len(ds), ds[2])
print(list(ds))
4 (2, 4)
[(0, 0), (1, 1), (2, 4), (3, 9)]

حلقه‌ی for در list(ds) حتی بدون متد __iter__ هم کار می‌کند. پایتون متد __getitem__ را با مقادیر 0، 1، 2 و الی آخر صدا می‌زند تا زمانی که به خطای IndexError برسد. دیتاست‌های سبک نگاشتی (map-style) در PyTorch نیز از همین دو متد __len__ و __getitem__ استفاده می‌کنند.

5. تکرارکننده‌ها و مولدها

تکرارپذیر یا iterable هر شیئی است که حلقه‌ی for آن را می‌پذیرد. تابع iter() یک تکرارکننده (iterator) از آن می‌گیرد و متد next() یک آیتم بعدی را از تکرارکننده درخواست می‌کند. وقتی آیتم‌ها تمام شوند، خطای StopIteration رخ می‌دهد. حلقه‌ی for خودش متد next() را صدا می‌زند و با رسیدن به StopIteration متوقف می‌شود.

numbers = [10, 20, 30]
it = iter(numbers)
print(next(it), next(it), next(it))
try:
    next(it)
except StopIteration:
    print("no more items")


def countdown(n):
    while n > 0:
        yield n
        n -= 1


gen = countdown(3)
print(type(gen).__name__)
print(list(gen))
print(list(gen))
10 20 30
no more items
generator
[3, 2, 1]
[]

تابعی که در بدنه‌ی خود yield داشته باشد، یک تابع مولد (generator function) است. فراخوانی آن هیچ بخشی از کدهایش را اجرا نمی‌کند و یک شیء مولد برمی‌گرداند. هر بار اجرای next() بدنه را تا yield بعدی پیش می‌برد و همان‌جا موقتاً متوقف می‌شود. دومین فراخوانی list(gen) خالی است چون اولی تمام مولد را مصرف کرده است.

یک مولد می‌تواند فایل را خط به خط بخواند.

import os
import tempfile


def read_rows(path):
    with open(path, encoding="utf-8") as fh:
        header = fh.readline().rstrip("\n").split(",")
        for line in fh:
            yield dict(zip(header, line.rstrip("\n").split(",")))


with tempfile.TemporaryDirectory() as tmp:
    path = os.path.join(tmp, "rows.csv")
    with open(path, "w", encoding="utf-8") as fh:
        fh.write("x,label\n")
        fh.writelines(f"{i * 0.5},{i % 2}\n" for i in range(5))
    rows = read_rows(path)
    print(next(rows))
    print(sum(1 for _ in rows))
{'x': '0.0', 'label': '0'}
4

تابع read_rows در هر لحظه یک خط از فایل را در حافظه نگه می‌دارد. این همان read_rows موجود در تصویر است. برای کوتاه ماندن مثال، خطوط بر اساس کاما جدا شده‌اند؛ اما فایل‌های واقعی CSV ممکن است درون فیلدهای داخل کوتیشن هم کاما داشته باشند، بنابراین در بخش پیاده‌سازی از صفر از ماژول csv استفاده می‌کنیم.

import sys

as_list = [i * i for i in range(1_000_000)]
as_generator = (i * i for i in range(1_000_000))
print(sys.getsizeof(as_list), sys.getsizeof(as_generator))
print(sum(as_list) == sum(as_generator))

روی CPython نسخه 64 بیتی، لیست حدود 8 مگابایت و مولد حدود 200 بایت گزارش می‌شود. دستور sys.getsizeof فقط اشاره‌گرهای لیست را می‌شمارد و اشیاء عددی حافظه‌ی بیشتری مصرف می‌کنند. در بخش پیاده‌سازی از صفر، اوج مصرف واقعی حافظه را با ماژول tracemalloc اندازه می‌گیریم؛ همان ماژولی که در درس عیب‌یابی و پروفایلینگ به کار بردیم.

6. کانتکست منیجرها و استثناها

یک دستور with کدهای آماده‌سازی، سپس بدنه‌ی اصلی و در آخر کدهای پاک‌سازی را اجرا می‌کند. پاک‌سازی حتی در صورت بروز استثنا (exception) در بدنه هم اجرا می‌شود. شیء فایلی که open() برمی‌گرداند، یک کانتکست منیجر (context manager) است که فایل را می‌بندد.

در درس عیب‌یابی و پروفایلینگ، کلاس Timer را با متدهای __enter__ و __exit__ نوشتیم. دکوراتور contextlib.contextmanager دقیقاً همین نوع شیء را از یک مولد می‌سازد: کدهای قبل از yield مرحله‌ی آماده‌سازی‌اند و بلوک finally کدهای پاک‌سازی است.

import time
from contextlib import contextmanager


@contextmanager
def timer(label):
    start = time.perf_counter()
    try:
        yield
    finally:
        print(f"{label}: {(time.perf_counter() - start) * 1000:.1f} ms")


def parse_label(text):
    try:
        return int(text)
    except ValueError:
        raise ValueError(f"label must be an integer, got {text!r}") from None


with timer("sum of squares"):
    total = sum(i * i for i in range(200_000))

print(parse_label("3"))
try:
    parse_label("cat")
except ValueError as err:
    print("rejected:", err)
sum of squares: 9.4 ms
3
rejected: label must be an integer, got 'cat'

زمان اجرا روی سیستم شما متفاوت خواهد بود. همیشه نوع دقیق خطایی را که انتظارش را دارید در عبارت except ذکر کنید؛ مثل ValueError یا KeyError. یک عبارت except: ساده و بدون تعیین نوع استثنا، KeyboardInterrupt را هم مدیریت می‌کند؛ در نتیجه فشردن Ctrl+C ممکن است نتواند حلقه‌ی حاوی آن را متوقف کند. زمانی که ورودی نادرست است، با پیامی شفاف استثنا صادر کنید. وقتی پیامتان جایگزین دنباله‌ی خطای اولیه می‌شود، عبارت from None را اضافه کنید.

7. تایپ هینت‌ها، ماژول‌ها و گارد main

تایپ هینت‌ها (type hints) به خواننده می‌گویند تابع چه ورودی‌هایی می‌گیرد و چه چیزی برمی‌گرداند. پایتون هنگام اجرای کد آن‌ها را بررسی نمی‌کند؛ بلکه ابزارهای بررسی نوع مثل mypy، پیش از اجرای کد آن‌ها را می‌خوانند.

from __future__ import annotations

from collections.abc import Iterable, Sequence


def mean(values: Sequence[float]) -> float:
    if not values:
        raise ValueError("mean() needs at least one value")
    return sum(values) / len(values)


def count_tokens(docs: Iterable[str]) -> int:
    return sum(len(doc.split()) for doc in docs)


def main() -> None:
    print(mean([1.0, 2.0, 4.0]))
    print(count_tokens(["the cat sat", "on the mat"]))
    print(mean.__annotations__)


if __name__ == "__main__":
    main()
2.3333333333333335
6
{'values': 'Sequence[float]', 'return': 'float'}

با from __future__ import annotations، همان‌طور که در خط آخر دیده می‌شود، پایتون هر هینت را به‌صورت یک رشته ذخیره می‌کند. کلمه‌ی Sequence یعنی هر شیئی با len() و قابلیت اندیس‌گذاری، مثل لیست یا تاپل. کلمه‌ی Iterable هم یعنی هر شیئی که حلقه‌ی for آن را می‌پذیرد. خیلی از فایل‌های دوره همین نام‌ها را از ماژول typing وارد می‌کنند که آن روش هم کار می‌کند.

هر فایل .py یک ماژول است. وقتی فایلی را با دستور python3 file.py اجرا می‌کنید، پایتون مقدار __name__ آن را روی "__main__" تنظیم می‌کند. اما وقتی فایل دیگری آن را وارد کند، مقدار __name__ برابر با نام همان ماژول خواهد بود. گارد شرطی بالا فقط در حالت اول main() را صدا می‌زند. به لطف همین گارد، تست‌های موجود در مسیر code/tests/ می‌توانند بدون اجرای دمو، فایل main.py را وارد کنند.

8. فایل‌ها، JSON ،CSV و مرتب‌سازی

کلاس pathlib.Path مسیرها را با / به هم وصل می‌کند و می‌تواند کل فایل را با یک فراخوانی بخواند یا بنویسد. همیشه encoding="utf-8" را مشخص کنید، چون انکودینگ پیش‌فرض به سیستم‌عامل بستگی دارد.

import csv
import json
import tempfile
from pathlib import Path

rows = [
    {"text": "great movie", "label": 1, "score": 0.93},
    {"text": "boring plot", "label": 0, "score": 0.12},
    {"text": "fine acting", "label": 1, "score": 0.58},
]

with tempfile.TemporaryDirectory() as tmp:
    json_path = Path(tmp) / "reviews.json"
    json_path.write_text(json.dumps(rows, indent=2), encoding="utf-8")
    from_json = json.loads(json_path.read_text(encoding="utf-8"))

    csv_path = Path(tmp) / "reviews.csv"
    with csv_path.open("w", newline="", encoding="utf-8") as fh:
        writer = csv.DictWriter(fh, fieldnames=["text", "label", "score"])
        writer.writeheader()
        writer.writerows(rows)
    with csv_path.open(newline="", encoding="utf-8") as fh:
        from_csv = list(csv.DictReader(fh))

print(from_json == rows)
print(from_csv[0])

by_score = sorted(rows, key=lambda r: r["score"], reverse=True)
print([r["text"] for r in by_score])
print(max(rows, key=lambda r: r["score"])["text"])
print([r["text"] for r in sorted(rows, key=lambda r: (-r["label"], r["text"]))])
True
{'text': 'great movie', 'label': '1', 'score': '0.93'}
['great movie', 'fine acting', 'boring plot']
great movie
['fine acting', 'great movie', 'boring plot']

فرمت JSON اعداد را به‌شکل عدد نگه می‌دارد. اما CSV همه‌ی فیلدها را به‌صورت رشته برمی‌گرداند، بنابراین باید هر فیلد را تبدیل کنید. طبق توصیه‌ی مستندات ماژول csv، فایل‌های CSV را با آرگومان newline="" باز کنید.

تابع key= مقداری را برمی‌گرداند که پایتون برای مقایسه‌ی آیتم‌ها از آن استفاده می‌کند. توابع sorted، min و max همگی آن را می‌پذیرند. وقتی کلید یک تاپل باشد، مرتب‌سازی بر اساس مقدار اول انجام می‌شود و فقط در صورت برابری به سراغ مقدار دوم می‌رود. علامت منفی باعث می‌شود برچسب بزرگ‌تر اول بیاید. الگوریتم مرتب‌سازی پایتون پایدار (stable) است، یعنی آیتم‌هایی با کلیدهای برابر، همان ترتیب اولیه‌ی خود را حفظ می‌کنند.

9. شمارش، گروه‌بندی و ابزارهای تکرارکننده

این دو ماژول، ساختارهای داده و ابزارهای تکرارکننده‌ای را فراهم می‌کنند که در لیست‌ها و دیکشنری‌های ساده وجود ندارند.

from collections import Counter, defaultdict, deque
from itertools import chain, islice, product

labels = ["cat", "dog", "cat", "bird", "cat", "dog"]
counts = Counter(labels)
print(counts.most_common(2))

positions = defaultdict(list)
for i, label in enumerate(labels):
    positions[label].append(i)
print(dict(positions))

recent = deque(maxlen=3)
for loss in [2.0, 1.5, 1.2, 1.1, 0.9]:
    recent.append(loss)
print(list(recent), round(sum(recent) / len(recent), 3))

print(list(product([0.1, 0.01], [16, 32])))
print(list(chain([1, 2], [3], [4, 5])))
print(list(islice(range(100), 5, 10)))
[('cat', 3), ('dog', 2)]
{'cat': [0, 2, 4], 'dog': [1, 5], 'bird': [3]}
[1.2, 1.1, 0.9] 1.067
[(0.1, 16), (0.1, 32), (0.01, 16), (0.01, 32)]
[1, 2, 3, 4, 5]
[5, 6, 7, 8, 9]

ساختار Counter آیتم‌های هش‌پذیر (hashable) مانند برچسب‌ها یا توکن‌ها را می‌شمارد. کلاس defaultdict(list) اولین باری که از کلید جدیدی استفاده کنید، یک لیست خالی برای آن می‌سازد؛ در نتیجه حلقه‌ی گروه‌بندی نیازی به بررسی وجود کلید ندارد. ساختار deque(maxlen=3) وقتی عضو چهارم را اضافه کنید، قدیمی‌ترین عضو را بیرون می‌اندازد، که یک پنجره‌ی متحرک روی مقادیر اخیر تابع زیان به شما می‌دهد. تابع enumerate هم هر آیتم را همراه با اندیس آن تحویل می‌دهد.

تابع product تمام ترکیب‌های ممکن ورودی‌ها را تولید می‌کند که همان شبکه‌ی ابرپارامترها (hyperparameter grid) است. تابع chain تکرارپذیرها را سر‌به‌سر وصل می‌کند. ابزار islice بخشی از هر تکرارکننده‌ای را برش می‌زند، حتی اگر مولد باشد. مولد مینی‌بچ در بخش پیاده‌سازی از صفر از islice استفاده می‌کند.

10. چرا حلقه‌ها روی اعداد کند هستند

ماژول dis دستورهای بایت‌کدی را که مفسر برای اجرای یک تابع به کار می‌برد چاپ می‌کند.

import dis


def dot(a, b):
    total = 0.0
    for x, y in zip(a, b):
        total += x * y
    return total


dis.dis(dot)

دستورهای بین FOR_ITER تا پرشِ بازگشتی به آن، برای تک‌تک عناصر یک بار تکرار می‌شوند؛ یعنی حدود ده دستورالعمل برای هر ضرب و جمع. نام دقیق این دستورها در نسخه‌های مختلف پایتون کمی فرق می‌کند.

هرچه مراحل کمتری از حلقه در قالب بایت‌کد اجرا شوند، حلقه سریع‌تر می‌شود. توابع توکار مانند sum، map و zip با زبان C نوشته شده‌اند. بخش پیاده‌سازی از صفر این اثر را در پایتون خالص اندازه می‌گیرد و بخش استفاده از ابزارهای واقعی کل حلقه را به NumPy منتقل می‌کند.

پیاده‌سازی از صفر

اکنون بخش‌هایی را می‌سازیم که درس‌های بعدی به آن‌ها نیاز دارند. نسخه‌ی کامل در مسیر code/main.py قرار دارد و فایل code/tests/test_main.py آن را تست می‌کند. هر بلوک کد زیر به‌تنهایی اجرا می‌شود.

گام 1: تعریف ساختار رکورد و بارگذار دیتاست

بارگذار، فایل CSV یا JSON را در قالبی یکپارچه می‌خواند: یک تاپل از ویژگی‌های اعشاری و یک برچسب عدد صحیح.

import csv
import json
import tempfile
from dataclasses import dataclass
from pathlib import Path


@dataclass(frozen=True)
class Record:
    features: tuple[float, ...]
    label: int


def row_to_record(row, names, label_field="label"):
    if label_field not in row:
        raise ValueError(f"row has no {label_field!r} field: {row}")
    if set(row) != {*names, label_field}:
        raise ValueError(f"row fields {list(row)} do not match {[*names, label_field]}")
    return Record(tuple(float(row[name]) for name in names), int(row[label_field]))


def load_records(path, label_field="label"):
    path = Path(path)
    if path.suffix == ".json":
        rows = json.loads(path.read_text(encoding="utf-8"))
    elif path.suffix == ".csv":
        with path.open(newline="", encoding="utf-8") as fh:
            rows = list(csv.DictReader(fh))
    else:
        raise ValueError(f"unsupported file type: {path.suffix!r}")
    if not rows:
        return []
    names = [key for key in rows[0] if key != label_field]
    return [row_to_record(row, names, label_field) for row in rows]


with tempfile.TemporaryDirectory() as tmp:
    csv_path = Path(tmp) / "data.csv"
    csv_path.write_text("x1,x2,label\n1.5,60,1\n1.8,82.5,0\n", encoding="utf-8")
    json_path = Path(tmp) / "data.json"
    json_path.write_text('[{"x1": 1.5, "x2": 60, "label": 1}]', encoding="utf-8")
    print(load_records(csv_path))
    print(load_records(json_path))
[Record(features=(1.5, 60.0), label=1), Record(features=(1.8, 82.5), label=0)]
[Record(features=(1.5, 60.0), label=1)]

سطر اول، نام ویژگی‌ها و ترتیب آن‌ها را مشخص می‌کند. کلاس DictReader و json.loads ترتیب کلیدها را بر اساس فایل حفظ می‌کنند و دیکشنری هم ترتیب درج کلیدها را نگه می‌دارد. استاندارد JSON ترتیبی برای کلیدهای درون شیء تعیین نمی‌کند، بنابراین تابع row_to_record هر سطر را بر اساس نام و مطابق با ترتیب سطر اول می‌خواند. اگر برچسبی نباشد، ویژگی کم یا زیاد باشد، یا نوع فایل ناشناخته باشد، خطای ValueError همراه با پیام صادر می‌شود تا فایل خراب در همان نقطه متوقف شود. فایل main.py تابعی به نام save_records هم دارد که داده‌ها را در همین دو فرمت می‌نویسد.

گام 2: تقسیم داده‌های آموزش و تست با دانه‌ی تصادفی

import random


def train_test_split(items, test_fraction=0.2, seed=0):
    if not 0.0 < test_fraction < 1.0:
        raise ValueError(f"test_fraction must be between 0 and 1, got {test_fraction}")
    if len(items) < 2:
        raise ValueError(f"need at least 2 items to split, got {len(items)}")
    order = list(range(len(items)))
    random.Random(seed).shuffle(order)
    n_test = min(max(1, round(len(items) * test_fraction)), len(items) - 1)
    test = [items[i] for i in order[:n_test]]
    train = [items[i] for i in order[n_test:]]
    return train, test


items = list(range(10))
print(train_test_split(items, seed=1))
print(train_test_split(items, seed=1) == train_test_split(items, seed=1))
print(train_test_split(items, seed=2)[1])
print(items)
([9, 7, 5, 3, 0, 4, 1, 2], [6, 8])
True
[5, 9]
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

دستور random.Random(seed) یک مولد اعداد تصادفی اختصاصی می‌سازد. در مقابل، تابع random.seed(seed) همان تک‌مولد سراسری مشترک بین همه‌ی ماژول‌ها را بازنشانی می‌کند؛ بنابراین فراخوانی آن در کتابخانه‌ای دیگر می‌تواند دنباله‌ی شما را تغییر دهد. در کدهای دوره، 100 فایل random.seed را صدا می‌زنند و 96 فایل شیء random.Random می‌سازند. در کدهای جدید از مولد اختصاصی استفاده کنید.

این تابع لیستی از اندیس‌ها را بُر می‌زند، بنابراین لیست ورودی دست‌نخورده باقی می‌ماند. دانه‌ی تصادفی یکسان همیشه تقسیم یکسانی تولید می‌کند. در هر بخش حداقل یک آیتم نگه داشته می‌شود، بنابراین لیستی با کمتر از 2 عضو خطای ValueError می‌دهد. همیشه تقسیم داده را قبل از هر مرحله‌ای که قرار است از آن یاد بگیرد (مانند استانداردسازی) انجام دهید. در درس عیب‌یابی و پروفایلینگ دیدیم که هم‌پوشانی داده‌های آموزش و تست باعث بالا رفتن غیرواقعی نتایج ارزیابی می‌شود.

گام 3: مولد مینی‌بچ

from itertools import islice


def batches(items, batch_size, drop_last=False):
    if batch_size < 1:
        raise ValueError(f"batch_size must be at least 1, got {batch_size}")
    return _batch_iter(iter(items), batch_size, drop_last)


def _batch_iter(iterator, batch_size, drop_last):
    while True:
        batch = list(islice(iterator, batch_size))
        if not batch or (drop_last and len(batch) < batch_size):
            return
        yield batch


print(list(batches(range(10), 4)))
print(list(batches(range(10), 4, drop_last=True)))
squares = (i * i for i in range(7))
print([len(b) for b in batches(squares, 3)])

for epoch in range(2):
    sizes = [len(b) for b in batches(range(10), 4)]
    print(f"epoch {epoch}: {sizes}")
[[0, 1, 2, 3], [4, 5, 6, 7], [8, 9]]
[[0, 1, 2, 3], [4, 5, 6, 7]]
[3, 3, 1]
epoch 0: [4, 4, 2]
epoch 1: [4, 4, 2]

دستور islice حداکثر تا batch_size آیتم از تکرارکننده برمی‌دارد. تابع batches فقط به iter() نیاز دارد، بنابراین لیست‌ها، فایل‌ها و مولدهای دیگر را هم می‌پذیرد. اگر طول داده‌ها مضربی از اندازه‌ی بچ نباشد، آخرین بچ کوچک‌تر خواهد بود. تنظیم drop_last=True آن بچ را حذف می‌کند؛ این قابلیت برای مدلی که به ابعاد ثابت بچ نیاز دارد مناسب است.

کار در دو تابع تقسیم شده است، چون یک تابع مولد تا پیش از فراخوانی اولین next() هیچ کدی را اجرا نمی‌کند. اگر بررسی ورودی را درون خود مولد می‌گذاشتیم، فراخوانی batches(data, 0) بدون هیچ خطایی برمی‌گشت و خطای واقعی بعداً از درون حلقه‌ی آموزش رخ می‌داد. تابع بیرونی یک تابع معمولی است، بنابراین آرگومان را بی‌درنگ ارزیابی می‌کند. حلقه‌ی دور آموزش باید در هر دور دوباره batches() را صدا بزند، زیرا مولد آیتم‌هایش را فقط یک بار تحویل می‌دهد.

گام 4: محاسبات برداری و استانداردساز

import math


def dot(a, b):
    return sum(x * y for x, y in zip(a, b, strict=True))


def mean(values):
    if not values:
        raise ValueError("mean() needs at least one value")
    return sum(values) / len(values)


def std(values):
    mu = mean(values)
    return math.sqrt(sum((v - mu) ** 2 for v in values) / len(values))


def make_standardizer(rows):
    columns = list(zip(*rows))
    means = [mean(c) for c in columns]
    scales = [s if s > 0 else 1.0 for s in (std(c) for c in columns)]

    def standardize(row):
        return [(v - m) / s for v, m, s in zip(row, means, scales, strict=True)]

    return standardize


print(dot([1.0, 2.0, 3.0], [4.0, 5.0, 6.0]))
values = [2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0]
print(mean(values), std(values))

train_rows = [(1.0, 100.0), (2.0, 300.0), (3.0, 500.0)]
standardize = make_standardizer(train_rows)
print([[round(v, 3) for v in standardize(r)] for r in train_rows])
print([round(v, 3) for v in standardize((4.0, 700.0))])
32.0
5.0 2.0
[[-1.225, -1.225], [0.0, 0.0], [1.225, 1.225]]
[2.449, 2.449]

با تنظیم strict=True، اگر طول دو بردار یکسان نباشد تابع zip خطای ValueError می‌دهد. بدون آن، zip با تمام شدن ورودیِ کوتاه‌تر متوقف می‌شود و پاسخی نادرست را بدون خطا برمی‌گرداند. آرگومان strict به پایتون 3.10 یا بالاتر نیاز دارد.

تابع std بر n تقسیم می‌کند، بنابراین انحراف معیار جامعه را حساب می‌کند؛ درست مانند متد statistics.pstdev. اگر برآورد پراکندگی جامعه‌ای بزرگ‌تر بر اساس یک نمونه مد نظر باشد، باید بر n - 1 تقسیم کنید.

تابع make_standardizer یک کلوژر است. میانگین‌ها و انحراف معیارها را یک بار از روی سطرهای آموزش حساب می‌کند و تابع standardize آن‌ها را نگه می‌دارد. همین تابع را روی سطرهای تست اعمال می‌کنید، در نتیجه داده‌های تست هیچ اثری روی آماره‌ها نمی‌گذارند. اگر ستونی مقداری ثابت داشته باشد، انحراف معیار آن 0 است؛ بنابراین استانداردساز مقیاس 1 را برای آن در نظر می‌گیرد.

گام 5: اجرای کامل برنامه

فایل code/main.py این قطعات را به یکدیگر وصل می‌کند. 400 رکورد از دو خوشه‌بندی می‌سازد، آن‌ها را در دو قالب CSV و JSON ذخیره کرده و دوباره هر دو را می‌خواند. سپس رکوردها را تقسیم می‌کند، استانداردساز را روی داده‌های آموزش برازش می‌دهد و مرکز خوشه‌ها را با بچ‌های 48 تایی حساب می‌کند.

طبقه‌بند نزدیک‌ترین مرکز (nearest-centroid classifier) بردار میانگین ویژگی‌های هر کلاس را ذخیره می‌کند. این طبقه‌بند کلاسی را پیش‌بینی می‌کند که میانگین آن به ورودی نزدیک‌تر باشد. تابع fit_centroids سطرها را بچ‌به‌بچ جمع می‌زند، بنابراین هیچ‌وقت لازم نیست کل مجموعه‌ی آموزش را یکجا در حافظه نگه دارد. متد predict هم از min() همراه با تابع key= برای یافتن نزدیک‌ترین مرکز استفاده می‌کند.

python3 phases/00-setup-and-tooling/13-python-for-ai-engineering/code/main.py
Python for AI engineering: a standard-library toolkit
data     400 records, CSV and JSON load back unchanged: True
split    320 train, 80 test, records in both: 0
batches  size 48: [48, 48, 48, 48, 48, 48, 32]
scale    train feature means after standardizing: [0.0, 0.0]
model    nearest-centroid test accuracy: raw 0.86, standardized 0.89
speed    dot product of 200,000 floats, best of 5:
           index loop         6.60 ms
           zip generator      5.93 ms
           map + mul          2.89 ms
memory   peak bytes to sum 100,000 squares:
           list              4,000,616
           generator               472

این دو ویژگی مقیاس‌های بسیار متفاوتی دارند. پراکندگی ویژگی دوم ده برابر اولی است، بنابراین فاصله‌های خام را تحت‌الشعاع قرار می‌دهد و دقت خام پایین‌تر می‌آید. اعداد مربوط به سرعت و حافظه روی سیستم شما با این اعداد تفاوت خواهد داشت.

گام 6: سنجش سرعت و مصرف حافظه

import operator
import random
import time
import tracemalloc


def dot_index_loop(a, b):
    total = 0.0
    for i in range(len(a)):
        total += a[i] * b[i]
    return total


def dot_zip(a, b):
    return sum(x * y for x, y in zip(a, b, strict=True))


def dot_map(a, b):
    return sum(map(operator.mul, a, b))


def best_time(fn, *args, repeat=5):
    times = []
    for _ in range(repeat):
        start = time.perf_counter()
        fn(*args)
        times.append(time.perf_counter() - start)
    return min(times)


def peak_bytes(fn):
    tracemalloc.start()
    try:
        fn()
        return tracemalloc.get_traced_memory()[1]
    finally:
        tracemalloc.stop()


rng = random.Random(0)
a = [rng.random() for _ in range(1_000_000)]
b = [rng.random() for _ in range(1_000_000)]
for fn in (dot_index_loop, dot_zip, dot_map):
    print(f"{fn.__name__:15} {best_time(fn, a, b) * 1000:7.2f} ms")

print(peak_bytes(lambda: sum([i * i for i in range(100_000)])))
print(peak_bytes(lambda: sum(i * i for i in range(100_000))))

تابع best_time سریع‌ترین زمان را در میان چند بار اجرا گزارش می‌کند. برنامه‌های دیگر روی سیستم فقط می‌توانند زمان را طولانی‌تر کنند، پس کمترین زمان، نزدیک‌ترین عدد به هزینه‌ی واقعی است. مستندات ماژول timeit هم دقیقاً همین نکته را توصیه می‌کند.

روی لپ‌تاپ آزمایشی ما با Python 3.11، حلقه‌ی اندیسی حدود 33 میلی‌ثانیه زمان برد. نسخه‌ی zip حدود 30 میلی‌ثانیه و نسخه‌ی map حدود 14 میلی‌ثانیه طول کشید. هر سه یک محاسبه‌ی یکسان را انجام می‌دهند؛ نسخه‌ی map سریع‌تر است چون map و operator.mul با زبان C نوشته شده‌اند و به ازای هر عنصر، بایت‌کدهای کمتری اجرا می‌شوند. آمارهای حافظه نیز تصویر را تکرار می‌کنند: حدود 4 مگابایت برای لیست و کمتر از 1 کیلوبایت برای مولد.

استفاده از ابزارهای واقعی

معادل‌های کتابخانه‌ی استاندارد

کتابخانه‌ی استاندارد نسخه‌ی تست‌شده‌ی اغلب بخش‌هایی را که ساختید دارد.

پیاده‌سازی شما کتابخانه‌ی استاندارد توضیحات
mean, std statistics.fmean, statistics.pstdev تست‌ها با این‌ها مقایسه می‌شوند
dot math.sumprod در Python 3.12 یا جدیدتر
batches itertools.batched در Python 3.12 یا جدیدتر، تاپل برمی‌گرداند
best_time timeit.repeat بازیافت حافظه (garbage collection) را در طول اجرا غیرفعال می‌کند
peak_bytes tracemalloc همان ماژول، همراه با اسنپ‌شات و آمارهای خط‌به‌خط
import statistics
import sys

values = [2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0]
print(statistics.fmean(values), statistics.pstdev(values))

if sys.version_info >= (3, 12):
    import math
    from itertools import batched

    print(math.sumprod([1.0, 2.0, 3.0], [4.0, 5.0, 6.0]))
    print(list(batched(range(10), 4)))
5.0 2.0
32.0
[(0, 1, 2, 3), (4, 5, 6, 7), (8, 9)]

این دوره از Python 3.11 پشتیبانی می‌کند، بنابراین فایل main.py از dot و batches خودش استفاده می‌کند. در Python 3.11، بلوک بالا تنها خط اول را چاپ می‌کند.

محاسبات یکسان با آرایه‌های کامپایل‌شده

کتابخانه‌ی NumPy را در محیط مجازی (virtual environment) ساخته‌شده در درس محیط‌های پایتون نصب کنید؛ مثلاً با دستور uv pip install numpy. کدهای درس و تست‌ها نیازی به آن ندارند.

import time

import numpy as np

n = 1_000_000
rng = np.random.default_rng(0)
a = rng.random(n)
b = rng.random(n)
a_list, b_list = a.tolist(), b.tolist()

start = time.perf_counter()
slow = sum(x * y for x, y in zip(a_list, b_list))
python_ms = (time.perf_counter() - start) * 1000

start = time.perf_counter()
fast = float(a @ b)
numpy_ms = (time.perf_counter() - start) * 1000

print(f"pure Python {python_ms:8.2f} ms")
print(f"NumPy       {numpy_ms:8.2f} ms")
print(f"difference  {abs(slow - fast):.1e}")
print(a.dtype, a.itemsize, a.nbytes)

روی لپ‌تاپ آزمایشی ما، محاسبات NumPy کمتر از 1 میلی‌ثانیه طول کشید، در حالی که نسخه‌ی پایتون خالص حدود 30 میلی‌ثانیه زمان برد. این دو نتیجه در ارقام آخر با هم تفاوت دارند، زیرا این دو نسخه حاصل‌ضرب‌ها را با ترتیب متفاوتی جمع می‌زنند. جمع ممیز شناور در ترتیب‌های متفاوت، نتایج کمی متفاوت تولید می‌کند.

کتابخانه‌ی NumPy به سه دلیل سریع‌تر عمل می‌کند:

  • آرایه مقادیر خام float64 را با اندازه‌ی هر کدام 8 بایت، در یک بلوک حافظه نگه می‌دارد. در حالی که یک لیست از اعداد اعشاری به ازای هر مقدار به یک اشاره‌گر 8 بایتی و یک شیء اعشاری 24 بایتی نیاز دارد.
  • حلقه در کدهای کامپایل‌شده اجرا می‌شود. NumPy فقط یک بار نوع داده را برای کل آرایه بررسی می‌کند و برای تک‌تک عناصر هیچ شیء پایتونی نمی‌سازد.
  • قرار گرفتن مقادیر کنار هم در حافظه به NumPy اجازه می‌دهد از دستورات SIMD استفاده کند؛ دستورهایی که چند مقدار را با یک دستورالعمل پردازش می‌کنند.

برای آرایه‌های بسیار کوچک، هزینه‌ی ثابتِ یک فراخوانی در NumPy می‌تواند بیشتر از یک حلقه‌ی کوتاه پایتونی باشد. در تمرین 4 اندازه‌ای را که NumPy در آن روی سیستم شما سریع‌تر می‌شود پیدا خواهید کرد.

پخش (Broadcasting)

پخش به NumPy اجازه می‌دهد آرایه‌هایی با ابعاد متفاوت را بدون نیاز به نوشتن حلقه ترکیب کند. این بلوک کد همان کار make_standardizer را از گام 4 انجام می‌دهد.

import numpy as np

X = np.array([[1.0, 100.0], [2.0, 300.0], [3.0, 500.0]])
mu = X.mean(axis=0)
sigma = X.std(axis=0)
Z = (X - mu) / sigma
print(X.shape, mu.shape, Z.shape)
print(Z.round(3))

norms = np.sqrt((X ** 2).sum(axis=1, keepdims=True))
print(norms.shape, (X / norms).shape)
(3, 2) (2,) (3, 2)
[[-1.225 -1.225]
 [ 0.     0.   ]
 [ 1.225  1.225]]
(3, 1) (3, 2)

کتابخانه‌ی NumPy ابعاد دو آرایه را از بعد آخر به سمت بعد اول مقایسه می‌کند. دو بعد زمانی سازگارند که برابر باشند یا یکی از آن‌ها 1 باشد. اگر بعد ابتدایی وجود نداشته باشد، 1 شمرده می‌شود. بعدی که اندازه‌اش 1 است، در امتداد بعد بزرگ‌تر دوباره استفاده می‌شود و NumPy کپی جدیدی از داده‌ها نمی‌سازد.

ابعاد X ابعاد آرایه‌ی دیگر نتیجه
(3, 2) (2,)، میانگین ستون‌ها (3, 2)
(3, 2) (3, 1)، نُرم سطرها با keepdims=True (3, 2)
(3, 2) (3,)، نُرم سطرها بدون keepdims ValueError

سطر آخر یکی از خطاهای رایج است. کتابخانه‌ی NumPy عدد 2 پایانی را با 3 مقایسه می‌کند و هیچ‌کدام هم 1 نیستند. وقتی روی سطرها کاهش بعد (reduction) انجام می‌دهید و سپس نتیجه را با آرایه‌ی اصلی ترکیب می‌کنید، مقدار keepdims=True را حفظ کنید.

خروجی این درس

این درس فایل outputs/prompt-python-review.md را تولید می‌کند. این فایل یک پرامپت (prompt) بازبینی برای کدهای پایتون در پروژه‌های هوش مصنوعی و داده است. یک فایل یا diff را به آن بدهید؛ کد را از نظر الگوها و خطاهای بی‌صدای این درس بررسی می‌کند:

  • ارجاع‌های مشترک و آرگومان‌های پیش‌فرض تغییرپذیر
  • مولدهایی که بعد از تمام شدن، دوباره در دور دوم خوانده می‌شوند
  • اعداد تصادفی بدون دانه، یا فقط با دانه‌ی سراسری
  • آماره‌هایی که از داده‌های بخش تست استخراج شده‌اند
  • حلقه‌های پایتون خالص روی آرایه‌های عددی بزرگ

این پرامپت هر مورد را با محل دقیق، مشکل و راه اصلاح گزارش می‌دهد.

تمرین‌ها

  1. تابع run_demo(seed=8) را از فایل main.py اجرا کنید. خطوطی از خروجی را که تغییر می‌کنند و خطوطی را که ثابت می‌مانند مشخص کنید. توضیح دهید چرا اندازه‌ی بخش‌های تفکیک‌شده ثابت می‌ماند.
  2. تابع median(values) را در پایتون خالص بنویسید. آن را با statistics.median روی لیست‌هایی با طول فرد و زوج مقایسه و ارزیابی کنید.
  3. تابع stream_records(path) را بنویسید؛ مولدی که در هر لحظه یک Record را از فایل CSV تولید کند. فایلی با 200,000 سطر بسازید. اوج مصرف حافظه‌ی stream_records و load_records را با tracemalloc مقایسه کنید.
  4. تابع matvec(matrix, vector) را در پایتون خالص و در NumPy بنویسید. زمان اجرای هر دو را با best_time برای ابعاد 4، 64 و 1024 اندازه بگیرید. ابعادی را که NumPy روی سیستم شما در آن سریع‌تر می‌شود پیدا کنید.
  5. برنامه‌ی اسکن ساختارهای کد بخش مفاهیم پایه را بنویسید. با ast.parse و ast.walk تمام فایل‌های موجود در مسیر phases/*/*/code/*.py را بررسی کنید. فایل‌هایی را که شامل ast.ListComp، ast.Yield و ast.With هستند بشمارید و با مقادیر جدول مقایسه کنید.

راهنمایی‌ها

تمرین 1. تابع run_demo را از code/main.py وارد کرده و آن را یک بار با seed=7 و بار دیگر با seed=8 صدا بزنید، سپس هر دو نتیجه را با format_report چاپ کنید. مقادیری که از داده‌های تصادفی می‌آیند تغییر می‌کنند؛ اما اندازه‌ی بخش‌ها فقط به n و test_fraction بستگی دارد و بنابراین ثابت می‌ماند.

تمرین 2. ابتدا یک کپی از لیست را مرتب کنید، سپس برای طول‌های فرد عنصر وسط و برای طول‌های زوج میانگین دو عنصر وسط را بردارید. نتیجه‌ی خود را با statistics.median روی لیست‌هایی مثل [3, 1, 2] و [4, 1, 3, 2] مقایسه کنید؛ هر دو باید مقادیری کاملاً یکسان بدهند.

تمرین 3. تابع load_records را در code/main.py بخوانید و مراحل پردازش آن را حفظ کنید، اما به‌جای اضافه کردن به لیست، هر Record را yield کنید. با استفاده از save_records همراه با make_dataset(200_000) فایل را بسازید، سپس peak_bytes(lambda: load_records(path)) را با peak_bytes(lambda: sum(1 for _ in your_generator(path))) مقایسه کنید. مولد باید حتماً درون تابع فراخوانی‌شونده مصرف شود، وگرنه peak_bytes فقط ساخت آن را اندازه می‌گیرد. این مقدار باید اوج مصرف بسیار کمتری را نشان دهد.

تمرین 4. از تابع dot به‌عنوان الگو برای پایتون خالص استفاده کنید و آن را برای هر سطر صدا بزنید، و برای NumPy از np.array(matrix) @ np.array(vector) کمک بگیرید. زمان هر دو را با best_time بسنجید و برای هر اندازه یک سطر در جدول خروجی چاپ کنید؛ پیش از مقایسه‌ی زمان‌ها، مطمئن شوید خروجی هر دو کاملاً برابر است.

تمرین 5. هر فایل را با Path.read_text بخوانید، آن را با ast.parse تحلیل کنید، و از any(isinstance(node, ast.ListComp) for node in ast.walk(tree)) برای بررسی یک ساختار استفاده کنید. هر فایل را یک بار بشمارید، زیرا جدول بخش مفاهیم پایه تعداد فایل‌ها را شمرده است.

اصطلاحات کلیدی

اصطلاح در گفت‌وگوها معنای دقیق فنی
تکرارپذیر (Iterable) «چیزی که بشه روش حلقه زد» شیئی که ورودی تابع iter() باشد، مثل یک لیست، فایل یا مولد
تکرارکننده (Iterator) «یک نشانگر یا کرسر» شیئی که متد __next__ آن در هر فراخوانی یک عضو برمی‌گرداند و در پایان خطای StopIteration می‌دهد
مولد (Generator) «یک لیست تنبل» تکرارکننده‌ای حاصل از یک تابع با yield یا عبارت مولد؛ هر آیتم را طبق تقاضا می‌سازد و فقط یک بار اجرا می‌شود
کامپریهنشن (Comprehension) «حلقه‌ی تک‌خطی» عبارتی برای ساخت لیست، دیکشنری یا مجموعه از روی یک شیء تکرارپذیر، با امکان فیلتر اختیاری
کلوژر (Closure) «تابع درون تابع» تابعی که متغیرهای تابع سازنده‌اش را درون خود نگه می‌دارد
دیتاکلاس (Dataclass) «یک استراکت» کلاسی که پایتون متدهای __init__، __repr__ و __eq__ آن را بر اساس فیلدهای مشخص‌شده با تایپ هینت می‌نویسد
کانتکست منیجر (Context manager) «بلوک with» شیئی که کدهای آماده‌سازی را در آغاز بلوک with و کدهای پاک‌سازی را در پایان (حتی بعد از رخ دادن استثنا) اجرا می‌کند
دانه‌ی تصادفی (Seed) «عدد تصادفی» مقدار اولیه‌ی یک مولد شبه‌تصادفی؛ دانه‌ی یکسان همیشه دنباله‌ای یکسان تولید می‌کند
کد برداری‌شده (Vectorized code) «کد سریع» کدی که یک عملیات را روی کل یک آرایه اعمال می‌کند تا کدهای کامپایل‌شده حلقه را اجرا کنند
پخش (Broadcasting) «تطبیق خودکار ابعاد» قوانین پایتونی NumPy برای ترکیب آرایه‌های ناهم‌شکل از طریق استفاده‌ی دوباره از ابعاد با اندازه‌ی 1 بدون کپی کردن داده‌ها

برای مطالعه‌ی بیشتر

خودآزمایی

به سؤال‌ها جواب بدهید تا ببینید چقدر از درس در ذهنتان مانده است.

  1. پیش از درسیک اسکریپت آموزش را دو بار اجرا می‌کنید و هر بار امتیاز آزمون متفاوتی می‌گیرید. کدام تغییر باعث می‌شود تفکیک داده‌های آموزش و آزمون در هر اجرا یکسان باشد؟
  2. حین درسفراخوانی یک تابع مولد (generator function) چه چیزی برمی‌گرداند؟
  3. حین درسفراخوانی batches(range(10), 4) از مقدار پیش‌فرض drop_last=False استفاده می‌کند. این فراخوانی چه اندازه‌هایی برای دسته‌ها برمی‌گرداند؟
  4. حین درسچرا ضرب داخلی با پایتون خالص روی یک میلیون عدد اعشاری بسیار کُندتر از ضرب داخلی با NumPy است؟
  5. پس از درستابع def add(item, bucket=[]) مقدار item را به bucket اضافه کرده و آن را برمی‌گرداند. شما ابتدا add(1) و سپس add(2) را فراخوانی می‌کنید. فراخوانی دوم چه چیزی برمی‌گرداند؟
  6. پس از درسپیش از آموزش مدل، ویژگی‌ها را استانداردسازی می‌کنید. میانگین‌ها و انحراف‌معیارها باید از کجا محاسبه شوند؟

سؤالات متداول

چرا حلقه‌های ساده پایتون در مقایسه با NumPy روی آرایه‌های عددی کندتر هستند؟

لیست پایتون مجموعه‌ای از اشاره‌گرها به اشیاء مستقل در حافظه است؛ مفسر برای هر عنصر نوع داده را بررسی و بایت‌کدهای متعددی را اجرا می‌کند. در مقابل، NumPy داده‌های خام همگن را در بلوکی پیوسته نگه می‌دارد و محاسبات را با حلقه‌های کامپایل‌شده C و دستورات SIMD انجام می‌دهد.

چرا مولدها (Generators) فقط یک بار در حلقه‌ها پیمایش می‌شوند؟

مولدها داده‌ها را از قبل در حافظه ذخیره نمی‌کنند، بلکه با هر درخواست شیء بعدی را می‌سازند و پس از اتمام آخرین آیتم خطای StopIteration می‌دهند. اگر در اپوک بعدی دوباره روی همان متغیر حلقه بزنید، داده‌ای دریافت نخواهید کرد و باید مولد جدید بسازید.

چرا مقدار پیش‌فرض یک تابع نباید شیء تغییرپذیری مانند لیست یا دیکشنری باشد؟

پایتون مقادیر پیش‌فرض آرگومان‌ها را تنها یک بار و در لحظه‌ی تعریف تابع ارزیابی می‌کند. بنابراین تمامی فراخوانی‌ها به یک شیء مشترک ارجاع خواهند داد و تغییر آن در یک فراخوانی، مقدار پیش‌فرض فراخوانی‌های بعدی را دستخوش تغییر می‌کند.

ما رو توی گوگل بیشتر ببین

با افزودن نکست ادمین به «منابع ترجیحی» گوگل، درس‌ها و مطالب ما را در بخش اخبار برتر جستجو بیشتر می‌بینید.