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

فصل ۵ از ۹

پیشرفت ترم
۰٪

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

اشکال‌یابیِ روشمند

فصل ۵پیش‌نمایش رایگان

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

وقتی کدت روی ۷۸۲ ردیف می‌ترکد، کارِ رایج این است که به کد خیره شوی و print بکاری. این فصل به‌جایش سه روش می‌دهد که هرکدام یک عددِ قابلِ‌اندازه‌گیری دارند.

جست‌وجوی دوبخشی ردیفِ مقصر را در ۱۰ گام پیدا می‌کند، به‌جای ۷۸۲ بار امتحان. بعد یک کوچک‌کننده می‌نویسیم که ۷۸۲ ردیف را با ۲۲ اجرا به ۱ ردیف می‌رساند. و آخرش سراغِ باگی می‌رویم که جست‌وجوی دوبخشی روی آن دروغ می‌گوید — ردیفی را متهم می‌کند که به‌تنهایی سالم است — و همان کوچک‌کننده با ۳۷ اجرا جفتِ واقعیِ مقصر را پیدا می‌کند.

یک آونگِ دوبخشی روی نواری از قطعه‌ها که یکی از آن‌ها معیوب است

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

  • با pdb در لحظهٔ خطا بایستی و مقدارِ متغیرها را ببینی
  • فضای جست‌وجوی یک باگ را نصف‌نصف کوچک کنی
  • کوچک‌ترین ورودیِ بازتولیدکننده را خودکار بسازی
  • assert را به‌عنوان تله بگذاری تا باگِ بعدی سرِ جای خودش گیر بیفتد

قبل از شروع#

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

از سرنخ ترمِ ۱ فصل ۸: خواندنِ traceback از پایین به بالا. اینجا فرض می‌کنیم بلدی و یک قدم جلوتر می‌رویم.

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

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

۱. باگی که سرِ ۷۸۲ ردیف می‌ترکد#

import json
from pathlib import Path

Path("features.py").write_text(r'''def clean(text):
    return text.strip()


def words(text):
    return clean(text).split()


def average_word_length(text):
    parts = words(text)
    return sum(len(w) for w in parts) / len(parts)


def summarise(rows):
    return sum(average_word_length(r["text"]) for r in rows) / len(rows)
''', encoding="utf-8")

import features

rows = [t for t in MESSY if isinstance(t["text"], str)]
print("ردیف‌ها:", len(rows))

try:
    features.summarise(rows)
except Exception as exc:
    print(f"{type(exc).__name__}: {exc}")
ردیف‌ها: 782
ZeroDivisionError: division by zero

«تقسیم بر صفر» — و همین. نه می‌گوید کدام ردیف، نه چه متنی، نه چندتا از این‌ها هست. این دقیقاً همان خطایی است که آدم را وسوسه می‌کند سیزده print در چهار تابع بکارد.

۲. pdb: به‌جای حدس، بایست و نگاه کن#

pdb اشکال‌یابِ خودِ پایتون است. کارِ اصلی‌اش این است که در لحظهٔ خطا برنامه را نگه دارد تا بتوانی همان‌جا متغیرها را بپرسی.

اینجا با -c دستورها را از قبل به آن می‌دهیم تا نوت‌بوک منتظرِ تایپِ تو نماند: continue یعنی «برو تا وقتی چیزی بترکد»، بعد p text و p parts یعنی «این دو متغیر را چاپ کن».

import os
import subprocess
import sys

with open("messy.jsonl", "w", encoding="utf-8") as f:
    for r in rows:
        f.write(json.dumps(r, ensure_ascii=False) + "\n")

Path("crash.py").write_text(r'''import json

import features

with open("messy.jsonl", encoding="utf-8") as f:
    rows = [json.loads(line) for line in f]

print(features.summarise(rows))
''', encoding="utf-8")

done = subprocess.run(
    [sys.executable, "-m", "pdb", "-c", "continue", "-c", "p text", "-c", "p parts",
     "-c", "quit", "crash.py"],
    capture_output=True, text=True, encoding="utf-8")

here = str(Path.cwd()) + os.sep
print("--- آخرین قابِ traceback ---")
for line in done.stderr.strip().splitlines()[-3:]:
    print(line.replace(here, ""))
print("--- پاسخِ pdb به p text و p parts ---")
for line in done.stdout.splitlines():
    print(line.replace(here, ""))
--- آخرین قابِ traceback ---
    return sum(len(w) for w in parts) / len(parts)
           ~~~~~~~~~~~~~~~~~~~~~~~~~~~^~~~~~~~~~~~
ZeroDivisionError: division by zero
--- پاسخِ pdb به p text و p parts ---
Uncaught exception. Entering post mortem debugging
Running 'cont' or 'step' will restart the program
''
[]

دو خطِ آخرِ خروجی، جوابِ p text و p parts هستند: متن '' بود و فهرستِ کلمه‌ها خالی. در همان لحظه فهمیدیم که ورودی یک متنِ بی‌کلمه بوده، بدونِ اینکه حتی یک print در کد بگذاریم.

در Colab راهِ معمولی‌ترش این است که %debug را در سلولِ بعدِ خطا بزنی و بعد همان دستورها را تایپ کنی. دستورهایی که بیشتر از همه لازم می‌شوند: p <نام> برای دیدنِ مقدار، l برای دیدنِ کدِ اطراف، u و d برای بالا و پایین رفتن بینِ قاب‌ها، و q برای خروج.

ولی pdb یک چیز را به‌ت نمی‌گوید: این خرابی چند تا از آن ۷۸۲ ردیف است و کدام‌ها. برای آن باید جست‌وجو کرد.

۳. دوبخشی‌کردن: ۱۰ گام به‌جای ۷۸۲#

ایده ساده است: نصفِ داده را امتحان کن. اگر شکست، مقصر آنجاست؛ اگر نشکست، در نصفِ دیگر است. هر گام فضای جست‌وجو را نصف می‌کند.

def breaks(subset):
    try:
        features.summarise(subset)
        return False
    except ZeroDivisionError:
        return True


def bisect(sample, still_breaks):
    lo, hi, steps = 0, len(sample), 0
    while hi - lo > 1:
        steps += 1
        mid = (lo + hi) // 2
        if still_breaks(sample[lo:mid]):
            hi = mid
        else:
            lo = mid
    return sample[lo], steps


culprit, steps = bisect(rows, breaks)
print("گام‌های جست‌وجوی دوبخشی:", steps, "در برابرِ", len(rows), "بار امتحانِ یکی‌یکی")
print("ردیفِ مقصر:", culprit["id"], "| متن:", repr(culprit["text"]))
print("همین یک ردیف به‌تنهایی می‌شکند؟", breaks([culprit]))
گام‌های جست‌وجوی دوبخشی: 10 در برابرِ 782 بار امتحانِ یکی‌یکی
ردیفِ مقصر: 1066 | متن: ''
همین یک ردیف به‌تنهایی می‌شکند؟ True

ده گام. و این عدد لگاریتمی است، نه خطی: برای ۷۸۲ ردیف ده گام، برای هشتصد هزار ردیف بیست گام.

و این روش فقط برای داده نیست. همان الگو روی تاریخچهٔ git می‌نشیند («کدام commit این را شکست؟»)، روی مراحلِ یک مسیرِ داده («کدام مرحله؟»)، و روی پیکربندی («کدام گزینه؟»). هر جا یک فضای مرتب و یک آزمونِ بله-خیر داری، دوبخشی‌کردن جواب می‌دهد.

چک کن: آن سطرِ آخر — «همین یک ردیف به‌تنهایی می‌شکند؟» — مهم‌ترین خطِ این بخش است و اغلب فراموش می‌شود. جست‌وجوی دوبخشی همیشه یک جواب برمی‌گرداند، حتی وقتی جوابش بی‌معنا باشد. تا وقتی مقصر را به‌تنهایی امتحان نکرده‌ای، فقط یک حدس داری. بخشِ ۵ نشان می‌دهد این چک چطور جانت را نجات می‌دهد.

۴. کوچک‌ترین نمونهٔ بازتولیدکننده#

دوبخشی‌کردن یک ردیف داد. ولی هدفِ واقعیِ اشکال‌یابی چیزِ دیگری است: کوچک‌ترین ورودی‌ای که هنوز خراب می‌شود — چیزی که بشود در سه خط برای یک نفرِ دیگر فرستاد.

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

def shrink(sample, still_breaks):
    current, chunk, tries = list(sample), len(sample) // 2, 0
    while chunk >= 1:
        index = 0
        while index < len(current):
            tries += 1
            candidate = current[:index] + current[index + chunk:]
            if candidate and still_breaks(candidate):
                current = candidate
            else:
                index += chunk
        chunk //= 2
    return current, tries


smallest, tries = shrink(rows, breaks)
print(f"از {len(rows)} ردیف به {len(smallest)} ردیف، با {tries} بار اجرا")
print("نمونهٔ کمینه:", smallest)
از 782 ردیف به 1 ردیف، با 22 بار اجرا
نمونهٔ کمینه: [{'id': 1250, 'user_id': 'u083', 'created_at': '2025-04-01T14:00', 'text': '', 'label': 'فنی'}]

بیست‌ودو اجرا، و حالا یک نمونهٔ یک‌ردیفی داری که هم گزارشِ باگ است، هم تستِ فردا.

و اسمِ ردیف را نگاه کن: 1250، نه 1066. دوبخشی اولین مقصری را می‌دهد که سرِ راهش سبز شود؛ کوچک‌کننده هر مقصری را که کافی باشد. هر دو درست‌اند چون این باگ چند ردیفِ مقصر دارد، نه یکی.

۵. وقتی دوبخشی دروغ می‌گوید#

حالا باگِ دوم، از جنسِ کاملاً متفاوت: باگی که به یک ردیف مربوط نیست، به یک جفت مربوط است. پیکرهٔ کثیف شناسه‌های تکراری دارد و ما جایی روی یکتاییِ شناسه حساب باز می‌کنیم:

def index_by_id(rows):
    seen = {}
    for r in rows:
        assert r["id"] not in seen, f"شناسهٔ تکراری: {r['id']}"
        seen[r["id"]] = r
    return seen


def breaks_ids(subset):
    try:
        index_by_id(subset)
        return False
    except AssertionError:
        return True


print("کلِ داده می‌شکند؟", breaks_ids(rows))
suspect, steps = bisect(rows, breaks_ids)
print("جست‌وجوی دوبخشی بعد از", steps, "گام می‌گوید مقصر:", suspect["id"])
print("ولی همین یک ردیف به‌تنهایی می‌شکند؟", breaks_ids([suspect]))

pair, tries = shrink(rows, breaks_ids)
print(f"کوچک‌سازی: {len(rows)} → {len(pair)} ردیف، با {tries} بار اجرا")
print("شناسه‌ها:", [r["id"] for r in pair])
کلِ داده می‌شکند؟ True
جست‌وجوی دوبخشی بعد از 10 گام می‌گوید مقصر: 1363
ولی همین یک ردیف به‌تنهایی می‌شکند؟ False
کوچک‌سازی: 782 → 2 ردیف، با 37 بار اجرا
شناسه‌ها: [1443, 1443]

جست‌وجوی دوبخشی با اطمینانِ کامل ردیفِ ۱۳۶۳ را متهم کرد، و آن ردیف به‌تنهایی هیچ ایرادی ندارد.

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

کوچک‌کننده این فرض را ندارد. هر تکه‌ای را حذف می‌کند که حذفش خرابی را از بین نبرد، و آخرش دقیقاً همان دو ردیفی می‌ماند که با هم مسئله می‌سازند: دو تیکت با شناسهٔ 1443.

📏 اندازه بگیر: با چه چیزی مقایسه شد؟ با امتحانِ یکی‌یکی (۷۸۲ اجرا) و با جست‌وجوی دوبخشی (۱۰ اجرا و در حالتِ دوم، یک جوابِ غلط). روی کدام داده؟ همان ۷۸۲ ردیفِ پیکرهٔ کثیف. با چند seed؟ هیچ‌کدام، و اینجا باید توضیح داد: هر دو روش قطعی‌اند — با همان ورودی همیشه همان گام‌ها را برمی‌دارند. عددی که تصادف ندارد seed هم نمی‌خواهد.

۶. assert به‌عنوان تله#

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

import traceback

Path("features2.py").write_text(r'''def clean(text):
    return text.strip()


def words(text):
    return clean(text).split()


def average_word_length(text, ticket_id=None):
    parts = words(text)
    assert parts, f"متنِ بدونِ کلمه در تیکتِ {ticket_id}: {text!r}"
    return sum(len(w) for w in parts) / len(parts)


def summarise(rows):
    return sum(average_word_length(r["text"], r["id"]) for r in rows) / len(rows)
''', encoding="utf-8")

import features2

try:
    features2.summarise(rows)
except AssertionError as exc:
    frames = traceback.extract_tb(exc.__traceback__)
    print(f"AssertionError: {exc}")
    print("عمقِ traceback:", len(frames), "قاب  |  ترکید در:", frames[-1].name)
AssertionError: متنِ بدونِ کلمه در تیکتِ 1066: ''
عمقِ traceback: 4 قاب  |  ترکید در: average_word_length

همان خطا، این بار با شناسهٔ تیکت داخلِ پیام. حالا کسی که این را در یک گزارشِ شبانه می‌بیند، در ده ثانیه ردیف را پیدا می‌کند به‌جای اینکه کلِ این فصل را دوباره اجرا کند.

قاعدهٔ گذاشتنِ تله: هر جا فرضی داری که کد بدونِ آن بی‌معنا می‌شود، همان فرض را assert کن. «این فهرست خالی نیست»، «این ستون وجود دارد»، «این دو آرایه هم‌اندازه‌اند». و هر assert باید مقداری را که مسئله‌ساز شد داخلِ پیامش بگذارد — همان قاعدهٔ فصلِ ۲، حالا در دلِ کدِ اصلی.

⚠️ مواظب باش: assert با پرچمِ -O پایتون حذف می‌شود. پس assert تله است برای پیدا کردنِ باگِ برنامه‌نویس، نه جای اعتبارسنجیِ ورودیِ کاربر. اعتبارسنجیِ واقعی باید raise صریح باشد — همان TypeError و ValueErrorی که در فصلِ ۴ ساختیم.

۷. و حالا تصمیم بگیر با ردیفِ بد چه کنی#

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

def summarise_v3(rows):
    total, counted, skipped = 0.0, 0, 0
    for r in rows:
        parts = features.words(r["text"])
        if not parts:
            skipped += 1
            continue
        total += sum(len(w) for w in parts) / len(parts)
        counted += 1
    return total / counted, counted, skipped


mean, counted, skipped = summarise_v3(rows)
print(f"میانگینِ طولِ کلمه: {mean:.4f}")
print(f"ردیفِ شمرده‌شده  : {counted}")
print(f"ردیفِ ردشده      : {skipped}")
میانگینِ طولِ کلمه: 4.2233
ردیفِ شمرده‌شده  : 771
ردیفِ ردشده      : 11

آن skipped تزیین نیست. تابعی که ردیف‌ها را بی‌صدا دور می‌ریزد، دقیقاً همان‌قدر خطرناک است که تابعی که می‌ترکد — فقط دیرتر معلوم می‌شود. اگر فردا این عدد از ۱۱ به ۴۰۰ برسد، تو باید ببینی‌اش، نه کاربر.

🔧 اگر کار نکرد: اگر AssertionError بخشِ ۶ را نگرفتی و به‌جایش TypeError: average_word_length() takes 1 positional argument but 2 were given دیدی، features2 را نساخته‌ای و پایتون هنوز نسخهٔ قبلی را در حافظه دارد. این همان تلهٔ فصلِ ۱ است: import دوباره فایل را نمی‌خواند. importlib.reload(features2) یا Restart session and run all.

🤖 از دستیارت بپرس: «git bisect چطور همین الگو را روی تاریخچهٔ commitها اجرا می‌کند؟» و بعد این را بپرس: «اگر آزمونم گاهی سبز و گاهی قرمز باشد، جست‌وجوی دوبخشی چه اتفاقی برایش می‌افتد؟» — جواب این است که کاملاً بی‌اعتبار می‌شود، و همین دلیلِ اهمیتِ تستِ غیرشکننده در فصلِ ۲ است.

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

کلمه تلفظ به حروف فارسی یعنی چه
pdb پی‌دی‌بی اشکال‌یابِ خودِ پایتون
post-mortem پست-مورتم ایستادن در لحظهٔ خطا و بررسیِ متغیرها
bisection بای‌سکشن نصف‌کردنِ پیاپیِ فضای جست‌وجو
minimal reproducer مینیمال ریپروسر کوچک‌ترین ورودی‌ای که هنوز خرابی را نشان می‌دهد
delta debugging دلتا دیباگینگ کوچک‌کردنِ خودکارِ ورودیِ خراب
git bisect گیت بای‌سکت همان دوبخشی‌کردن، روی تاریخچهٔ commitها — فعلاً همین‌قدر بدان؛ ترمِ ۶ فصلِ ۱ با آن یک باگِ واقعی را پیدا می‌کند

تمرین‌ها

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

در فصل بعد#

سه فصل روی درستی کار کردیم. حالا نوبتِ سرعت است — و اولین قاعده‌اش این است که حدست دربارهٔ کندیِ کد تقریباً همیشه غلط است.

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

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

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