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

فصل ۲ از ۹

پیشرفت ترم
۰٪

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

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

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

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

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

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

شانه‌ای از سنجه‌های اندازه که هرکدام روی یک قطعه می‌نشیند و یکی از قطعه‌ها از هیچ سنجه‌ای رد نمی‌شود

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

  • تستی بنویسی که وقتی می‌شکند، بگوید چه چیزی شکسته
  • مجموعهٔ تست را با یک تابع اجرا کنی و نتیجه را بخوانی
  • تستِ سریع را از تستِ کند جدا کنی و بدانی چرا این جداسازی لازم است
  • مجموعهٔ تستِ خودت را بسنجی و سوراخ‌هایش را پیدا کنی

قبل از شروع#

از فصلِ ۱: بستهٔ tickets با سه ماژول. هر نوت‌بوک از یک runtimeِ خالی شروع می‌شود، پس سلولِ اولِ این فصل همان بسته را دوباره می‌سازد — عیناً همان کد.

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

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

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

import json
from pathlib import Path

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

Path("tickets").mkdir(exist_ok=True)
Path("tickets/__init__.py").write_text("", encoding="utf-8")

Path("tickets/text.py").write_text(r'''import re


def normalise(text):
    text = text.replace("ي", "ی")
    return re.sub(r"\s+", " ", text).strip()
''', encoding="utf-8")

Path("tickets/data.py").write_text(r'''import json
from pathlib import Path

DATA = Path(__file__).resolve().parent.parent / "tickets.jsonl"


def load_tickets(path=DATA):
    with open(path, encoding="utf-8") as f:
        return [json.loads(line) for line in f if line.strip()]


def usable(rows):
    return [r for r in rows if r.get("text") and r.get("label")]
''', encoding="utf-8")

Path("tickets/model.py").write_text(r'''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

from tickets import text


def build():
    return make_pipeline(CountVectorizer(), LogisticRegression(max_iter=1000))


def train_and_score(rows, seed=0):
    X = [text.normalise(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=seed, stratify=y)
    model = build()
    model.fit(X_tr, y_tr)
    return model, model.score(X_te, y_te)
''', encoding="utf-8")

from tickets import data, model, text

print("بسته آماده است:", sorted(p.name for p in Path("tickets").glob("*.py")))
بسته آماده است: ['__init__.py', 'data.py', 'model.py', 'text.py']

💡 نکته: یک تفاوتِ ریز با فصلِ ۱ دارد و عمدی است: model.py این بار from tickets import text می‌نویسد و text.normalise(...) صدا می‌زند، نه from tickets.text import normalise. دلیلش را در بخشِ ۷ می‌بینی — و همان چیزی است که در فصلِ ۱ دربارهٔ reload گفتیم.

۱. assert: کوچک‌ترین تستِ ممکن#

assert یک جمله است: «این باید درست باشد». اگر نبود، برنامه همان‌جا می‌ایستد.

try:
    assert text.normalise("كارت") == "کارت"
except AssertionError as exc:
    print("assertِ بی‌پیام:", repr(str(exc)))

try:
    got = text.normalise("كارت")
    assert got == "کارت", f"انتظار 'کارت'، گرفتیم {got!r}"
except AssertionError as exc:
    print("assertِ باپیام :", str(exc))
assertِ بی‌پیام: ''
assertِ باپیام : انتظار 'کارت'، گرفتیم 'كارت'

هر دو یک چیز را فهمیدند و فقط یکی‌شان به تو گفت چه شد.

assertِ اولی می‌گوید «یک چیزی غلط بود». دومی می‌گوید «انتظار داشتم کارت باشد، كارت بود» — و همین یک رشته، تفاوتِ بینِ ده ثانیه و نیم‌ساعت است، مخصوصاً سه ماه بعد که یادت نیست آن خط چه‌کار می‌کرد.

قاعده‌ای که از همین‌جا برای کلِ دوره برمی‌داریم: هر assert باید پیامی داشته باشد که مقدارِ واقعی را نشان بدهد. نه «خطا رخ داد»، بلکه «این را انتظار داشتم، این را گرفتم».

۲. اجراکنندهٔ تستِ خودمان، در بیست خط#

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

def run_tests(namespace, verbose=True):
    names = sorted(n for n in namespace if n.startswith("test_"))
    failed = []
    for name in names:
        try:
            namespace[name]()
        except AssertionError as exc:
            failed.append((name, str(exc) or "بدونِ پیام"))
        except Exception as exc:
            failed.append((name, f"{type(exc).__name__}: {exc}"))
    if verbose:
        for name, why in failed:
            print(f"  ✗ {name} — {why}")
        print(f"{len(names) - len(failed)} از {len(names)} تست سبز")
    return len(names), len(failed)


def test_ya_arabic():
    got = text.normalise("مي‌خواهم")
    assert got == "می‌خواهم", f"ی عربی نرمال نشد: {got!r}"


def test_kaf_arabic():
    got = text.normalise("كارت بانكي")
    assert got == "کارت بانکی", f"ک عربی نرمال نشد: {got!r}"


def test_collapses_spaces():
    got = text.normalise("رمز    عبور")
    assert got == "رمز عبور", f"فاصله‌ها جمع نشدند: {got!r}"


def test_trims_edges():
    got = text.normalise("   بسته   ")
    assert got == "بسته", f"فاصلهٔ کناری نماند: {got!r}"


def test_empty_stays_empty():
    assert text.normalise("   ") == ""


run_tests(globals())
  ✗ test_kaf_arabic — ک عربی نرمال نشد: 'كارت بانكی'
4 از 5 تست سبز

سه تصمیم در این بیست خط هست:

۱) نامِ تست با test_ شروع می‌شود و اجراکننده از globals() پیدایشان می‌کند. یعنی برای اضافه کردنِ یک تست هیچ‌جا ثبتش نمی‌کنی — فقط می‌نویسی‌اش. هر ابزارِ تستِ واقعی هم دقیقاً همین قرارداد را دارد؛ حالا می‌دانی چرا اسمشان باید با test_ شروع شود.

۲) AssertionError جدا از بقیهٔ خطاها گرفته می‌شود. AssertionError یعنی «کد کار کرد ولی جوابش غلط بود». هر خطای دیگر یعنی «کد اصلاً کار نکرد». این دو، دو نوعِ خبرِ کاملاً متفاوت‌اند و مجموعهٔ تستی که این دو را قاتی کند، به‌ت دروغ می‌گوید.

۳) اولین شکست، اجرا را متوقف نمی‌کند. اگر چهار چیز شکسته باشد، می‌خواهی هر چهارتا را یک‌جا ببینی، نه اینکه چهار بار اجرا کنی.

و مهم‌تر از هر سه: تست اولین کاری که کرد، پیدا کردنِ یک باگِ واقعی بود. normalise ی عربی را درست می‌کند و ک عربی را نه. این باگ در فصلِ ۱ آنجا بود، در همان کدی که «کار می‌کرد»، و هیچ‌چیز نگفت.

۳. رفعِ باگ#

p = Path("tickets/text.py")
p.write_text(p.read_text(encoding="utf-8").replace(
    'text = text.replace("ي", "ی")',
    'text = text.replace("ي", "ی").replace("ك", "ک")'), encoding="utf-8")

import importlib
importlib.reload(text)

run_tests(globals())
5 از 5 تست سبز

🔧 اگر کار نکرد: اگر بعد از این ویرایش هنوز همان ✗ test_kaf_arabic — ک عربی نرمال نشد: 'كارت بانكی' را می‌بینی، فایل عوض شده ولی ماژولِ در حافظه نه. این همان تلهٔ فصلِ ۱ است: importlib.reload را جا انداخته‌ای، یا جایی from tickets.text import normalise نوشته‌ای که آن نام هنوز به تابعِ قدیمی وصل است. مطمئن‌ترین راه: Runtime → Restart session and run all.

۴. آن باگ چقدر مهم بود؟#

«یک باگ پیدا کردیم» یک جملهٔ خوش‌آیند است. جملهٔ مفید این است که آن باگ روی داده چه می‌کرد — و این را باید اندازه گرفت، نه حدس زد.

rows = data.load_tickets()
messy_texts = [t["text"] for t in MESSY if t["text"]]


def vocabulary(texts, fn):
    words = set()
    for t in texts:
        words.update(fn(t).split())
    return words


def old_normalise(t):
    import re
    return re.sub(r"\s+", " ", t.replace("ي", "ی")).strip()


before = vocabulary(messy_texts, old_normalise)
after = vocabulary(messy_texts, text.normalise)
print("ردیف‌های حاوی ک عربی:", sum("ك" in t for t in messy_texts))
print("واژگان پیش از رفعِ باگ:", len(before))
print("واژگان پس از رفعِ باگ :", len(after))
print("توکن‌های دوقلوی حذف‌شده:", len(before) - len(after))
ردیف‌های حاوی ک عربی: 41
واژگان پیش از رفعِ باگ: 607
واژگان پس از رفعِ باگ : 595
توکن‌های دوقلوی حذف‌شده: 12

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

📏 اندازه بگیر: با چه چیزی مقایسه شد؟ با همان تابع پیش از رفعِ باگ، روی همان متن‌ها. روی کدام داده؟ نسخهٔ کثیفِ پیکره (MESSY)، چون خرابیِ ک عربی دقیقاً همان‌جاست. با چند seed؟ هیچ — و عمداً: این یک شمارش است، نه یک آموزش. شمارشِ روی یک دادهٔ ثابت seed نمی‌خواهد؛ هر جا عددی از آموزشِ مدل بیرون بیاید، از فصلِ ۳ به بعد چند seed اجباری است.

۵. تستِ داده: قرارداد را هم بسنج#

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

def test_usable_drops_empty_text():
    kept = data.usable([{"text": "", "label": "فنی"}, {"text": "سلام", "label": "فنی"}])
    assert len(kept) == 1, f"انتظار ۱ ردیف، گرفتیم {len(kept)}"


def test_usable_drops_missing_label():
    kept = data.usable([{"text": "سلام", "label": ""}, {"text": "سلام", "label": "فنی"}])
    assert len(kept) == 1


def test_usable_keeps_order():
    src = [{"text": "الف", "label": "فنی"}, {"text": "ب", "label": "ارسال"}]
    assert [r["text"] for r in data.usable(src)] == ["الف", "ب"]


def test_load_reads_every_line():
    assert len(data.load_tickets()) == 769


run_tests(globals())
9 از 9 تست سبز

به ورودیِ سه تستِ اول نگاه کن: دو ردیفِ دست‌ساز. نه پیکرهٔ ۷۶۹تایی، نه فایل. تستِ خوب کوچک‌ترین ورودی‌ای را می‌سازد که سؤالش را جواب می‌دهد، چون آن‌وقت وقتی قرمز شد، هیچ چیزِ دیگری برای شک کردن باقی نمی‌ماند.

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

چک کن: عددِ 769 را در test_load_reads_every_line دستی نوشته‌ایم و این کار در تستِ داده معمولاً غلط است: فردا داده عوض می‌شود و یک تستِ سالم بی‌دلیل قرمز می‌شود. درستش این است که تست شکلِ داده را بسنجد، نه اندازه‌اش را — مثلاً «هیچ ردیفی بدونِ text نیست». اینجا عمداً نگهش داشتیم تا خودت ببینی چقدر زود آزارت می‌دهد.

۶. تستِ سریع در برابرِ تستِ کند#

import time

start = time.perf_counter()
run_tests(globals(), verbose=False)
fast = time.perf_counter() - start


def test_model_beats_baseline():
    rows = data.usable(data.load_tickets())
    _, score = model.train_and_score(rows)
    assert score > 0.25, f"از حدسِ تصادفی بهتر نیست: {score:.4f}"


start = time.perf_counter()
total, failed = run_tests(globals(), verbose=False)
slow = time.perf_counter() - start
print(f"بدونِ تستِ مدل: {total - 1} تست در {fast:.3f} ثانیه")
print(f"با تستِ مدل  : {total} تست در {slow:.3f} ثانیه")
print(f"یک تست، {slow / fast:.0f} برابرِ کلِ بقیه طول کشید")
بدونِ تستِ مدل: 9 تست در 0.004 ثانیه
با تستِ مدل  : 10 تست در 0.059 ثانیه
یک تست، 16 برابرِ کلِ بقیه طول کشید

یک تست، بیش از ده برابرِ همهٔ بقیه با هم. و این هنوز یک مدلِ کوچک روی ۷۶۹ ردیف است؛ در کارِ واقعی همین نسبت به‌راحتی هزار برابر می‌شود.

نتیجه‌اش یک تصمیمِ طراحی است، نه یک نکتهٔ سلیقه‌ای: مجموعهٔ تست را دو تکه کن.

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

چون تستی که کند است، اجرا نمی‌شود. و تستی که اجرا نمی‌شود، وجود ندارد. این تنها دلیلِ این جداسازی است — نه زیبایی، نه نظم.

⚠️ مواظب باش: به آستانهٔ score > 0.25 دقت کن. با چهار برچسبِ متوازن، حدسِ تصادفی همان حدودِ ۰٫۲۵ می‌گیرد، پس این تست فقط می‌گوید «مدل کاملاً مرده نیست». اگر بنویسی score > 0.90، یک تستِ شکننده ساخته‌ای که با هر تغییرِ کوچکِ داده قرمز می‌شود و کم‌کم یاد می‌گیری نادیده‌اش بگیری. تستِ مدل باید مرزِ فاجعه را بگیرد، نه بهترین عدد را. فصلِ بعد نشان می‌دهد چه تست‌هایی واقعاً برای مدل معنا دارند.

۷. تستی که هیچ‌وقت قرمز نمی‌شود#

حالا سخت‌ترین سؤالِ این فصل: از کجا می‌دانی مجموعهٔ تستت چیزی را می‌گیرد؟

جوابِ صادقانه‌اش یک آزمایش است: عمداً کد را خراب کن و ببین چند تست قرمز می‌شود. اگر خرابی‌ای بگذاری و همه‌چیز سبز بماند، آن خرابی در محدودهٔ کورِ تست‌های توست.

import re

ORIGINAL = text.normalise

MUTANTS = {
    "strip را بردار": lambda t: re.sub(r"\s+", " ", t.replace("ي", "ی").replace("ك", "ک")),
    "ی عربی را رها کن": lambda t: re.sub(r"\s+", " ", t.replace("ك", "ک")).strip(),
    "فاصله‌ها را جمع نکن": lambda t: t.replace("ي", "ی").replace("ك", "ک").strip(),
    "هر متنی را خالی برگردان": lambda t: "",
    "متن را دست‌نخورده برگردان": lambda t: t,
    "فقط ۲۰ نویسهٔ اول را نگه دار": lambda t: ORIGINAL(t)[:20],
}

for name, broken in MUTANTS.items():
    text.normalise = broken
    total, failed = run_tests(globals(), verbose=False)
    print(f"  {name:<30} {failed} تست از {total} قرمز شد")

text.normalise = ORIGINAL
  strip را بردار                 2 تست از 10 قرمز شد
  ی عربی را رها کن               2 تست از 10 قرمز شد
  فاصله‌ها را جمع نکن            1 تست از 10 قرمز شد
  هر متنی را خالی برگردان        5 تست از 10 قرمز شد
  متن را دست‌نخورده برگردان      5 تست از 10 قرمز شد
  فقط ۲۰ نویسهٔ اول را نگه دار   0 تست از 10 قرمز شد

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

چرا؟ چون هر پنج تستِ متنی ورودیِ کوتاه دارند — كارت بانكي ده نویسه است — و آن تستِ مدل هم فقط پرسیده بود «از ۰٫۲۵ بهتر است؟»، و مدلی که فقط بیست نویسهٔ اولِ هر تیکت را می‌بیند هنوز از حدسِ تصادفی خیلی بهتر است. پس هیچ‌کس چیزی نگفت، در حالی که این خرابی نصفِ اطلاعاتِ هر تیکت را دور می‌ریزد.

این آزمایش اسم دارد: mutation testing. ابزارهای آماده‌ای برایش هست، ولی چیزی که تازه نوشتی خودش نسخهٔ دستیِ همان است — و همین نسخهٔ دستی بیشترِ فایده را دارد، چون تو انتخاب می‌کنی خرابی‌ها کدام‌ها باشند و آن انتخاب، خودش فهرستِ چیزهایی است که واقعاً از کدت انتظار داری.

و به سطرهای دیگر هم نگاه کن. «فاصله‌ها را جمع نکن» فقط یک تست را قرمز کرد. یعنی تمامِ آن رفتار روی یک تست بند است؛ اگر روزی کسی همان یک تست را حذف کند، آن رفتار بی‌محافظ می‌ماند و هیچ‌کس نمی‌فهمد.

🤖 از دستیارت بپرس: «pytest چه چیزی به این اجراکنندهٔ بیست‌خطی اضافه می‌کند؟» جوابِ درست شاملِ fixture، اجرای گزینشی، و گزارشِ تفاوتِ خوانا است. بعد خودت این را بپرس: «اگر تستی از تستِ دیگری اثر بپذیرد، اجراکنندهٔ من چه رفتاری نشان می‌دهد؟» — و امتحانش کن: یک تست بنویس که text.normalise را عوض کند و برنگرداند، بعد کلِ مجموعه را اجرا کن. تستی که همسایه‌اش را خراب می‌کند، بدترین نوعِ تست است.

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

کلمه تلفظ به حروف فارسی یعنی چه
assert اَسرت جمله‌ای که اگر درست نباشد، اجرا را متوقف می‌کند
test suite تست سوئیت مجموعهٔ تست‌هایی که با هم اجرا می‌شوند
test runner تست رانر کدی که تست‌ها را پیدا و اجرا می‌کند و خلاصه می‌دهد
smoke test اسموک تست تستِ خیلی سطحی که فقط می‌پرسد «هنوز وصل است؟»
flaky test فلیکی تست تستی که گاهی سبز و گاهی قرمز می‌شود
mutation testing میوتیشن تستینگ خراب کردنِ عمدیِ کد برای سنجیدنِ خودِ تست‌ها
pytest پای‌تست ابزارِ رایجِ تست در پایتون — همین قرارداد، با امکاناتِ بیشتر

تمرین‌ها

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

در فصل بعد#

تست‌های این فصل کدِ معمولی را می‌سنجیدند: ورودی می‌دهی، خروجیِ معلومی انتظار داری. ولی مدل خروجیِ معلوم ندارد. نمی‌شود نوشت «model.predict باید پرداخت برگرداند» — اگر برنگرداند، شاید مدل اشتباه کند، شاید تو.

فصلِ بعد ⚠️ می‌گوید تستِ کدِ یادگیری ماشین چه شکلی است: تستِ ناوردایی، تستِ شکل و بازه، تستِ «مدل اصلاً چیزی یاد می‌گیرد؟» — و تستی که ثابت می‌کند آن 0.9016 فصلِ اول، هفت واحد بالاتر از واقعیت است.

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

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