داربست — خودآموز مهندسیِ سامانه‌های هوش مصنوعی (پیشرفته)

فصل ۶ از ۹

پیشرفت ترم
۰٪

ترم ۱ · کدی که می‌شود به آن تکیه کرد

اول اندازه بگیر، بعد سریع کن

فصل ۶پیش‌نمایش رایگان
۱۵ دقیقه مطالعه فصل ۶

در این فصل چه یاد می‌گیری#

یک مسیرِ چهارمرحله‌ای داریم: نرمال‌سازی، حذفِ تکراری، حذفِ ایست‌واژه، آموزشِ مدل. کدام‌شان گران‌ترین است؟

جوابِ همه — و جوابِ من قبل از اینکه اندازه بگیرم — «آموزشِ مدل» است. پروفایلر چیزِ دیگری می‌گوید: حذفِ ایست‌واژه نزدیک به سه برابرِ آموزشِ مدل وقت می‌برد. و آن مرحلهٔ O(n²) که نگرانش بودیم؟ یک‌ونیم میلی‌ثانیه — یعنی یک درصدِ کلِ زمان.

رفعِ همان یک نقطه، کلِ مسیر را ۳٫۵ برابر سریع می‌کند و دقت را یک هزارم هم عوض نمی‌کند. بعد یک بهینه‌سازیِ «بدیهی» را امتحان می‌کنیم که ۱٫۰۵ برابر می‌دهد — یعنی هیچ.

دو لولهٔ موازی که یکی‌شان تنگهٔ باریکِ واقعی را دارد

آخر این فصل می‌توانی:

  • با cProfile نقطهٔ داغِ واقعی را پیدا کنی، نه نقطهٔ داغِ خیالی
  • عددِ پروفایلر را با حسابِ سرانگشتی توضیح بدهی
  • با tracemalloc ببینی حافظه کجا می‌رود
  • بگویی چرا سنجیدنِ بعد از بهینه‌سازی به‌اندازهٔ سنجیدنِ قبلش لازم است

قبل از شروع#

از فصلِ ۴: آنجا دیدیم نگهبانی که به‌ازای هر ردیف اجرا شود، ده برابرِ خودِ کار هزینه دارد. این فصل همان پدیده است، این بار سیستماتیک.

قاعدهٔ کلی که کلِ فصل رویش سوار است: بیشترِ وقتِ برنامه در بخشِ کوچکی از کد می‌گذرد — به آن قاعدهٔ ۹۰-۱۰ می‌گویند. پس بهینه‌سازیِ بدونِ اندازه‌گیری، احتمالاً وقت گذاشتن روی همان ۹۰ درصدی است که مهم نیست.

اجرا زمانِ تقریبی
CPU (پیش‌فرضِ Colab) حدودِ یک دقیقه

📓 نوت‌بوک: نوت‌بوک این فصل را در Colab باز کن — همهٔ کدهای این فصل آماده و به‌ترتیب داخلش هست.

۱. مسیری که می‌خواهیم بسنجیم#

import re
import time

from sklearn.feature_extraction.text import CountVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
from sklearn.pipeline import make_pipeline

STOPWORDS = ["از", "به", "با", "که", "را", "در", "این", "آن", "برای", "هم"]
STOPWORDS += [f"واژه{i}" for i in range(1990)]        # فهرستِ ایست‌واژه‌های یک پروژهٔ واقعی


def normalise(text):
    text = text.replace("ي", "ی").replace("ك", "ک")
    return re.sub(r"\s+", " ", text).strip()


def drop_duplicates(rows):
    seen, kept = [], []
    for r in rows:
        if r["text"] not in seen:
            seen.append(r["text"])
            kept.append(r)
    return kept


def remove_stopwords(rows):
    out = []
    for r in rows:
        words = [w for w in r["text"].split() if w not in STOPWORDS]
        out.append({**r, "text": " ".join(words)})
    return out


def train(rows):
    X = [r["text"] for r in rows]
    y = [r["label"] for r in rows]
    X_tr, X_te, y_tr, y_te = train_test_split(
        X, y, test_size=0.25, random_state=0, stratify=y)
    model = make_pipeline(CountVectorizer(), LogisticRegression(max_iter=1000))
    model.fit(X_tr, y_tr)
    return model.score(X_te, y_te)


def run_all(rows):
    rows = [{**r, "text": normalise(r["text"])} for r in rows]
    rows = drop_duplicates(rows)
    rows = remove_stopwords(rows)
    return train(rows)


rows = [r for r in MESSY if isinstance(r["text"], str) and r["text"].strip() and r["label"] in LABELS]
print("ردیف‌های ورودی:", len(rows))
print("دقت:", round(run_all(rows), 4))
ردیف‌های ورودی: 757
دقت: 0.7953

قبل از رفتن به بخشِ بعد، حدست را بنویس: کدام مرحله گران‌ترین است؟ چند برابرِ بقیه؟ حدسِ من قبل از اجرا این بود: آموزشِ مدل، دستِ‌کم دو برابرِ مجموعِ بقیه.

و یک نگرانیِ نظری هم داشتم: drop_duplicates برای هر ردیف کلِ فهرستِ دیده‌شده‌ها را می‌گردد، یعنی رفتارش با مربعِ تعدادِ ردیف‌ها بزرگ می‌شود. این بدترین ساختارِ ممکن است و آدم را وسوسه می‌کند همان لحظه درستش کند.

۲. ساعتِ دستی روی هر مرحله#

stages = [
    ("normalise", lambda rs: [{**r, "text": normalise(r["text"])} for r in rs]),
    ("drop_duplicates", drop_duplicates),
    ("remove_stopwords", remove_stopwords),
]

current, total = rows, 0.0
for name, fn in stages:
    start = time.perf_counter()
    current = fn(current)
    spent = time.perf_counter() - start
    total += spent
    print(f"  {name:<18}{spent * 1000:>8.1f} میلی‌ثانیه")

start = time.perf_counter()
train(current)
spent = time.perf_counter() - start
total += spent
print(f"  {'train':<18}{spent * 1000:>8.1f} میلی‌ثانیه")
print(f"  {'جمع':<18}{total * 1000:>8.1f} میلی‌ثانیه")
  normalise              3.8 میلی‌ثانیه
  drop_duplicates        1.4 میلی‌ثانیه
  remove_stopwords      95.6 میلی‌ثانیه
  train                 34.4 میلی‌ثانیه
  جمع                  135.1 میلی‌ثانیه

هر دو حدس غلط بود.

remove_stopwords — کاری که به‌نظر می‌رسد فقط چند کلمه را دور می‌ریزد — نزدیک به سه برابرِ آموزشِ مدل طول کشید. و آن O(n²)ِ ترسناک، یک‌ونیم میلی‌ثانیه: حدودِ یک درصدِ کل.

⚠️ مواظب باش: عددهای این فصل روی ماشینِ تو دقیقاً همین‌ها نیستند و نباید باشند. چیزی که باید همان باشد نسبت‌هاست — اینکه remove_stopwords چند برابرِ train است. زمانِ مطلق به سخت‌افزار بند است؛ تصمیمِ مهندسی به نسبت.

۳. cProfile: تابع به تابع#

ساعتِ دستی به مرحله می‌رسد، نه به تابع. برای رسیدن به تابع، پروفایلر لازم است: برنامه را با شمارنده اجرا می‌کند و می‌گوید هر تابع چند بار صدا زده شد و چقدر طول کشید.

import cProfile
import pstats

profiler = cProfile.Profile()
profiler.enable()
run_all(rows)
profiler.disable()

stats = pstats.Stats(profiler)
OURS = {"run_all", "train", "remove_stopwords", "drop_duplicates", "normalise", "fit"}

table = {}
for (path, line, name), (calls, _prim, tottime, cumtime, _callers) in stats.stats.items():
    if name in OURS:
        table[name] = (calls, tottime, cumtime)

print(f"  {'تابع':<18}{'فراخوانی':>10}{'خودش':>12}{'با زیرمجموعه':>14}")
for name, (calls, tottime, cumtime) in sorted(table.items(), key=lambda kv: -kv[1][2]):
    print(f"  {name:<18}{calls:>10,}{tottime * 1000:>10.1f} ms{cumtime * 1000:>11.1f} ms")
  تابع                فراخوانی        خودش  با زیرمجموعه
  run_all                    1       0.7 ms      153.4 ms
  remove_stopwords           1      98.0 ms       98.8 ms
  train                      1       0.1 ms       46.6 ms
  fit                        1       0.0 ms       34.0 ms
  normalise                757       0.7 ms        5.6 ms
  drop_duplicates            1       1.6 ms        1.7 ms

دو ستونِ آخر دو چیزِ متفاوت‌اند و قاتی کردنشان رایج‌ترین اشتباهِ خواندنِ پروفایل است:

  • «خودش» یعنی وقتی که داخلِ خودِ آن تابع گذشت، بدونِ توابعی که صدا زده.
  • «با زیرمجموعه» یعنی از لحظهٔ ورود تا خروج، با هر چیزی که در این میان صدا زد.

run_all «خودش» تقریباً صفر است و «با زیرمجموعه» کلِ زمان — چون کارش فقط صدا زدنِ بقیه است. remove_stopwords برعکس: تقریباً همهٔ زمانش مالِ خودش است. پس متهم این است، نه چیزی که صدا می‌زند.

و به ستونِ فراخوانی هم نگاه کن: normalise هفتصد و پنجاه‌وهفت بار صدا زده شده و مجموعش شش میلی‌ثانیه است. تعدادِ فراخوانیِ زیاد به‌تنهایی هیچ معنایی ندارد.

💡 نکته: خروجیِ خامِ stats.print_stats() مسیرِ کاملِ فایل‌ها را چاپ می‌کند — چیزی که روی ماشینِ تو کاملاً فرق دارد. برای همین اینجا خودمان جدول را ساختیم و فقط نامِ تابع‌ها را نگه داشتیم. در کارِ روزمره همان print_stats(10) کافی است؛ اینجا برای خوانایی جمعش کردیم.

۴. چرا؟ حسابِ سرانگشتی#

پروفایلر می‌گوید کجا، نه چرا. «چرا» را باید خودت با یک ضرب و تقسیم دربیاوری:

words = sum(len(r["text"].split()) for r in rows)
print(f"کلمه‌های ورودی به remove_stopwords : {words:,}")
print(f"طولِ فهرستِ ایست‌واژه              : {len(STOPWORDS):,}")
print(f"بدترین حالتِ مقایسه               : {words * len(STOPWORDS):,}")
print(f"مقایسهٔ لازم با set                : {words:,}")
print(f"مقایسهٔ drop_duplicates با list    : {len(rows) * (len(rows) - 1) // 2:,}")
کلمه‌های ورودی به remove_stopwords : 10,473
طولِ فهرستِ ایست‌واژه              : 2,000
بدترین حالتِ مقایسه               : 20,946,000
مقایسهٔ لازم با set                : 10,473
مقایسهٔ drop_duplicates با list    : 286,146

بیست میلیون در برابرِ دویست‌وهشتاد هزار. حالا هر دو عددِ بخشِ ۲ توضیح دارند.

w not in STOPWORDS روی یک فهرست یعنی «از اول تا آخر بگرد». برای هر کلمه‌ای که ایست‌واژه نیست — یعنی بیشترشان — کلِ دو هزار عضو خوانده می‌شود. w not in STOP_SET روی یک مجموعه یعنی یک بار محاسبهٔ hash و یک بار نگاه. همان یک نویسه، [ به {، بیست میلیون مقایسه را به ده هزار می‌رساند.

🧱 بنیادش کجاست: آن «یک بار محاسبهٔ hash و یک بار نگاه» تنها جای این ترم است که به سازوکارِ زیرِ set و dict تکیه می‌کند. برای این فصل همین یک جمله کافی است: hash هر کلید را به شمارهٔ یک خانه تبدیل می‌کند، پس به‌جای گشتنِ کلِ ظرف، مستقیم سراغِ همان خانه می‌روی — و هزینه‌اش به تعدادِ اعضا بستگی ندارد. اگر عمقش را خواستی، بنیاد، ترم ۳ فصل ۲ — hash: از کلید تا شمارهٔ خانه از صفر می‌سازدش و ترم ۳ فصل ۵ — set و dict در عمل نقطهٔ سربه‌سرش را با جست‌وجوی خطی اندازه می‌گیرد. این پیش‌نیاز نیست؛ فصل بدونش کامل است.

و همان حساب می‌گوید چرا drop_duplicates مهم نبود: ۲۸۶ هزار مقایسه که هرکدام یک مقایسهٔ رشتهٔ سریع در C است. بزرگیِ نمادین O(n²) تا وقتی n کوچک است، عددِ کوچکی می‌ماند.

🧱 بنیادش کجاست: O(n²) در این فصل چهار بار می‌آید و برای درسِ این فصل یک جمله بس است: یعنی «اگر ورودی دو برابر شود، کار چهار برابر می‌شود». همین جمله توضیح می‌دهد چرا یک مرحلهٔ O(n²) روی دادهٔ کوچک بی‌ضرر است و روی دادهٔ صد برابر فاجعه. درسِ این فصل هم همین است و نه بیشتر: اول اندازه بگیر، بعد سریع کن. بنیاد، ترم ۱ فصل ۶ — شش منحنی که تقریباً همه‌چیز را توضیح می‌دهند شش مرتبهٔ رشد را با عددِ واقعی کنارِ هم می‌گذارد. روی صد برابرِ این داده جای این دو متهم عوض می‌شود — و آن روز هم پروفایلر به‌ت خبر می‌دهد، نه غریزه‌ات.

۵. رفعش کن، و دوباره بسنج#

STOP_SET = set(STOPWORDS)


def drop_duplicates_fast(rows):
    seen, kept = set(), []
    for r in rows:
        if r["text"] not in seen:
            seen.add(r["text"])
            kept.append(r)
    return kept


def remove_stopwords_fast(rows):
    out = []
    for r in rows:
        words = [w for w in r["text"].split() if w not in STOP_SET]
        out.append({**r, "text": " ".join(words)})
    return out


def run_fast(rows):
    rows = [{**r, "text": normalise(r["text"])} for r in rows]
    rows = drop_duplicates_fast(rows)
    rows = remove_stopwords_fast(rows)
    return train(rows)


def clock(fn, repeat=3):
    best = float("inf")
    for _ in range(repeat):
        start = time.perf_counter()
        fn()
        best = min(best, time.perf_counter() - start)
    return best


slow = clock(lambda: run_all(rows))
fast = clock(lambda: run_fast(rows))
print(f"پیش از رفعِ نقطهٔ داغ: {slow * 1000:.0f} میلی‌ثانیه")
print(f"پس از رفعِ نقطهٔ داغ : {fast * 1000:.0f} میلی‌ثانیه")
print(f"بهبود                : {slow / fast:.1f} برابر")
print("دقت عوض شد؟", round(run_all(rows), 4), "→", round(run_fast(rows), 4))
پیش از رفعِ نقطهٔ داغ: 139 میلی‌ثانیه
پس از رفعِ نقطهٔ داغ : 40 میلی‌ثانیه
بهبود                : 3.5 برابر
دقت عوض شد؟ 0.7953 → 0.7953

سه‌ونیم برابر، از دو تغییرِ یک‌خطی.

و آن سطرِ آخر اختیاری نیست. بهینه‌سازی‌ای که خروجی را عوض کند، بهینه‌سازی نیست — کدِ دیگری است که سریع‌تر جوابِ دیگری می‌دهد. برای همین هر تغییرِ سرعت باید کنارِ خودش یک مقایسهٔ خروجی داشته باشد؛ اینجا 0.7953 → 0.7953.

📏 اندازه بگیر: با چه چیزی مقایسه شد؟ با همان مسیر، همان ورودی، فقط دو ساختمانِ داده عوض شده. روی کدام داده؟ همان ۷۵۷ ردیفِ پیکرهٔ کثیف. با چند seed؟ seed اینجا بی‌ربط است چون هیچ تصادفی در سرعت نیست — ولی جایش تکرار داریم: clock هر اندازه‌گیری را سه بار می‌زند و کمینه را برمی‌دارد، نه میانگین. دلیلش این است: هر اختلالِ خارجی زمان را فقط زیاد می‌کند، هرگز کم نمی‌کند. پس کمینه به زمانِ واقعیِ کد نزدیک‌تر است.

۶. بهینه‌سازی‌ای که هیچ‌کاری نکرد#

حالا یک «بهینه‌سازیِ بدیهی» که همه توصیه‌اش می‌کنند: الگوی re را از قبل کامپایل کن تا هر بار دوباره ساخته نشود.

def normalise_faster(text, _pattern=re.compile(r"\s+")):
    return _pattern.sub(" ", text.replace("ي", "ی").replace("ك", "ک")).strip()


def run_faster(rows):
    rows = [{**r, "text": normalise_faster(r["text"])} for r in rows]
    rows = drop_duplicates_fast(rows)
    rows = remove_stopwords_fast(rows)
    return train(rows)


faster = clock(lambda: run_faster(rows))
print(f"با normalise از پیش کامپایل‌شده: {faster * 1000:.0f} میلی‌ثانیه")
print(f"در برابرِ                      : {fast * 1000:.0f} میلی‌ثانیه")
print(f"بهبود                          : {fast / faster:.2f} برابر")
با normalise از پیش کامپایل‌شده: 38 میلی‌ثانیه
در برابرِ                      : 40 میلی‌ثانیه
بهبود                          : 1.05 برابر

پنج درصد — که در نوسانِ خودِ اندازه‌گیری گم می‌شود. یعنی هیچ.

دو دلیل دارد و هر دو درس‌اند:

  • re خودش الگوها را cache می‌کند. re.sub(r"\s+", ...) بارِ دوم به بعد همان الگوی کامپایل‌شده را برمی‌دارد. پس کاری که کردیم، از قبل انجام شده بود.
  • و مهم‌تر: normalise از اول کمتر از شش میلی‌ثانیه بود. این شش میلی‌ثانیه چهار درصدِ مسیرِ کندِ ۱۳۹ میلی‌ثانیه‌ای است — ولی مقایسهٔ همین بخش روی مسیرِ سریعِ ۴۰ میلی‌ثانیه‌ای انجام شد، و آنجا همان شش میلی‌ثانیه حدودِ پانزده درصد است. حتی اگر بی‌نهایت سریعش می‌کردیم، سقفِ بهبود همان پانزده درصد بود؛ ما پنج درصد گرفتیم.

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

چک کن: اگر پیشِ تو این نسبت ۱٫۲ یا ۰٫۹ درآمد، هر دو یعنی همان «هیچ». عددی که با تکرارِ اندازه‌گیری بالا و پایین می‌رود، بهبود نیست. قاعدهٔ سرانگشتی: تا وقتی تفاوت از نوسانِ خودِ اندازه‌گیری بزرگ‌تر نشده، آن را باور نکن — همان قاعده‌ای که در فصلِ ۳ برای دقتِ مدل داشتیم، این بار برای زمان.

۷. حافظه هم پروفایل دارد#

زمان تنها منبع نیست. tracemalloc از خودِ پایتون می‌گوید کدام خط چقدر حافظه گرفت:

import tracemalloc

tracemalloc.start()
run_fast(rows)
current_mem, peak_mem = tracemalloc.get_traced_memory()
tracemalloc.stop()
print(f"اوجِ حافظهٔ کلِ مسیر: {peak_mem / 1024:.0f} کیلوبایت")

tracemalloc.start()
snapshot_before = tracemalloc.take_snapshot()
copies = [{**r, "text": normalise(r["text"])} for r in rows]
snapshot_after = tracemalloc.take_snapshot()
tracemalloc.stop()

top = snapshot_after.compare_to(snapshot_before, "lineno")[0]
print(f"گران‌ترین خط: {top.size_diff / 1024:.0f} کیلوبایت برای {len(copies)} کپیِ تازه")
اوجِ حافظهٔ کلِ مسیر: 1060 کیلوبایت
گران‌ترین خط: 148 کیلوبایت برای 757 کپیِ تازه

آن {**r, "text": ...} هر ردیف را از نو می‌سازد — و در این اندازه ارزان است. ولی مسیرِ ما سه مرحله دارد و هر مرحله همین کار را می‌کند، پس در اوجِ کار سه نسخهٔ کاملِ داده هم‌زمان زنده‌اند. روی ۷۵۷ ردیف بی‌اهمیت است؛ روی چند میلیون ردیف همین الگو همان چیزی است که runtime را می‌کشد.

و این دقیقاً موضوعِ فصلِ بعد است: کپیِ بی‌جا.

🔧 اگر کار نکرد: اگر tracemalloc عددهای خیلی کوچکی داد یا صفر، احتمالاً tracemalloc.start() را بعد از ساختنِ داده صدا زده‌ای. tracemalloc فقط تخصیص‌های بعد از start را می‌بیند و هر چه قبلش ساخته شده برایش نامرئی است. برای دیدنِ مصرفِ چیزی که از قبل وجود دارد، باید دوباره بسازی‌اش — همان کاری که در قطعهٔ دوم کردیم.

🤖 از دستیارت بپرس: «تفاوتِ tottime و cumtime در cProfile چیست و کدام‌شان برای پیدا کردنِ نقطهٔ داغ درست است؟» بعد خودت این را امتحان کن: run_all را با drop_duplicates روی دادهٔ ده برابر (rows * 10) پروفایل کن. حالا کدام متهم است؟ جوابْ همان چیزی است که در بخشِ ۴ پیش‌بینی کردیم و حالا خودت می‌بینی‌اش.

واژه‌های تازهٔ این فصل#

کلمه تلفظ به حروف فارسی یعنی چه
profiler پروفایلر ابزاری که می‌گوید وقت و حافظه کجا مصرف شد
cProfile سی‌پروفایل پروفایلرِ زمانِ خودِ پایتون
tottime تات‌تایم زمانی که داخلِ خودِ تابع گذشت
cumtime کام‌تایم زمانِ تابع به‌علاوهٔ هر چیزی که صدا زد
hotspot هات‌اسپات بخشِ کوچکی از کد که بیشترِ زمان آنجاست
tracemalloc تریس‌مالوک پروفایلرِ حافظهٔ خودِ پایتون

تمرین‌ها

اول خودت فکر کن یا امتحان کن — بعد اینجا را باز کن.

در فصل بعد#

حالا می‌دانیم نقطهٔ داغ کجاست. فصلِ بعد سراغِ ابزارهای سریع‌کردن می‌رود — برداری‌سازی به‌جای حلقه، حذفِ کپیِ بی‌جا، و cache روی تابع — و هر سه را با عدد می‌سنجد.

و یکی از آن سه، در این پیکره، هیچ سودی نمی‌دهد. خواهیم دید کدام و چرا.

به آخر این فصل رسیدی!

اگر ساختی و جواب داد، این دکمه مال توست.