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

فصل ۳ از ۹

پیشرفت ترم
۰٪

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

چرا تستِ کدِ یادگیری ماشین فرق دارد

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

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

تستِ فصلِ قبل یک قرارداد داشت: ورودیِ معلوم، خروجیِ معلوم. normalise("كارت") باید "کارت" بدهد و اگر نداد، باگ است.

مدل چنین قراردادی ندارد. نمی‌شود نوشت «این تیکت باید پرداخت بشود» — اگر نشد، شاید مدل اشتباه کند و شاید تو. پس چه چیزی را می‌شود تست کرد؟

این فصل چهار خانوادهٔ تست را می‌سازد که واقعاً معنا دارند، و با آن‌ها یک عددِ به‌ظاهر خوب را می‌شکند. قوی‌ترینشان این است: برچسب‌های داده را کاملاً تصادفی می‌کنیم — دیگر هیچ چیزی برای یاد گرفتن نمانده — و همان split معمولی به ما دقتِ ۰٫۵۸ می‌دهد. کفِ تصادف ۰٫۲۵ است. آن ۰٫۳۳ اضافه، چیزی جز حفظ کردن نیست.

دستگاهی که ورودی‌اش را از دو مسیر می‌گیرد و یک مسیر از فیلترِ ورودی رد نمی‌شود

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

  • تستِ شکل و بازه بنویسی و بگویی چرا ارزان‌ترین تستِ مدل‌اند
  • تستِ ناوردایی بنویسی و انحرافِ آموزش و سرویس را با آن بگیری
  • ثابت کنی مدلت چیزی یاد گرفته، نه اینکه حفظ کرده
  • نشتیِ ردیفِ تکراری را با یک assert بگیری

قبل از شروع#

از فصلِ ۲: اجراکنندهٔ تست و قاعدهٔ «هر assert پیام دارد».

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

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

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

۱. چرا «باید پرداخت بشود» تستِ بدی است#

import re

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


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


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


texts = [normalise(t["text"]) for t in TICKETS]
labels = [t["label"] for t in TICKETS]
X_tr, X_te, y_tr, y_te = train_test_split(
    texts, labels, test_size=0.25, random_state=0, stratify=labels)
model = build().fit(X_tr, y_tr)
print("آموزش:", len(X_tr), "| تست:", len(X_te), "| دقت:", round(model.score(X_te, y_te), 4))
آموزش: 576 | تست: 193 | دقت: 0.9016

حالا تستی که همه اول می‌نویسند: پنج تیکتِ دست‌ساز با جوابِ «درست».

SAMPLES = [
    "رمز عبورم را فراموش کرده‌ام و ایمیل بازیابی نمی‌آید",
    "مبلغ فاکتور دو بار از کارتم کم شد",
    "اپلیکیشن موقع بارگذاری خطا می‌دهد",
    "بسته را پیک نیاورد و وضعیت تحویل عوض نشد",
    "سلام پیگیری سفارش",
]
EXPECTED = ["حساب", "پرداخت", "فنی", "ارسال", "ارسال"]

for sample, want in zip(SAMPLES, EXPECTED):
    got = model.predict([normalise(sample)])[0]
    print(f"  {'✓' if got == want else '✗'} انتظار {want:<8} گرفتیم {got}")
  ✓ انتظار حساب     گرفتیم حساب
  ✓ انتظار پرداخت   گرفتیم پرداخت
  ✓ انتظار فنی      گرفتیم فنی
  ✓ انتظار ارسال    گرفتیم ارسال
  ✗ انتظار ارسال    گرفتیم پرداخت

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

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

درسِ اصلی: تستِ مدل نباید دربارهٔ یک پیش‌بینیِ خاص باشد، باید دربارهٔ یک ویژگیِ سامانه باشد. چهار بخشِ بعد چهار ویژگی را می‌سنجند.

۲. خانوادهٔ اول: شکل و بازه#

ارزان‌ترین تست‌هایی که داری. هیچ‌کدام کیفیتِ مدل را نمی‌سنجند — می‌سنجند که خروجی اصلاً همان چیزی است که بقیهٔ سامانه انتظار دارد.

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)


import numpy as np

BATCH = X_te[:20]


def test_predict_length():
    got = model.predict(BATCH)
    assert len(got) == len(BATCH), f"{len(BATCH)} ورودی، {len(got)} خروجی"


def test_proba_shape():
    proba = model.predict_proba(BATCH)
    assert proba.shape == (len(BATCH), 4), f"شکلِ غیرمنتظره: {proba.shape}"


def test_proba_sums_to_one():
    total = model.predict_proba(BATCH).sum(axis=1)
    assert np.allclose(total, 1.0), f"جمعِ احتمال‌ها ۱ نیست: {total[:3]}"


def test_proba_in_range():
    proba = model.predict_proba(BATCH)
    assert proba.min() >= 0.0 and proba.max() <= 1.0, "احتمال بیرونِ بازهٔ صفر و یک"


def test_no_nan():
    assert not np.isnan(model.predict_proba(BATCH)).any(), "NaN در خروجی"


def test_label_set():
    assert set(model.classes_) == set(LABELS), f"برچسب‌های ناشناخته: {model.classes_}"


def test_empty_text_does_not_crash():
    assert len(model.predict([""])) == 1


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

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

۳. خانوادهٔ دوم: ناوردایی#

سؤالش این است: کدام تغییرِ ورودی نباید جواب را عوض کند؟ برای متنِ فارسی جوابِ روشنی دارد — نوعِ صفحه‌کلید، فاصلهٔ اضافه، تعارفِ ابتدای پیام.

ZWNJ = "‌"


def flip_rate(predict, rows, change):
    base = predict(rows)
    other = predict([change(r) for r in rows])
    return sum(a != b for a, b in zip(base, other))


def serve(raw_texts):                     # مسیرِ سرویس‌دهی: متنِ کاربر مستقیم به مدل
    return model.predict(raw_texts)


def to_arabic(t):
    return t.replace("ی", "ي").replace("ک", "ك")


print("صفحه‌کلیدِ عربی    :", flip_rate(serve, X_te, to_arabic), "از", len(X_te))
print("نیم‌فاصله به فاصله :", flip_rate(serve, X_te, lambda t: t.replace(ZWNJ, " ")), "از", len(X_te))
print("فاصلهٔ اضافه       :", flip_rate(serve, X_te, lambda t: "  " + t + "  "), "از", len(X_te))
print("افزودنِ سلام       :", flip_rate(serve, X_te, lambda t: "با سلام و احترام " + t), "از", len(X_te))
صفحه‌کلیدِ عربی    : 70 از 193
نیم‌فاصله به فاصله : 0 از 193
فاصلهٔ اضافه       : 0 از 193
افزودنِ سلام       : 2 از 193

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

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

۴. رفعش — و سه صفری که باید توضیح داده شوند#

def serve_v2(raw_texts):
    return model.predict([normalise(t) for t in raw_texts])


print("صفحه‌کلیدِ عربی    :", flip_rate(serve_v2, X_te, to_arabic), "از", len(X_te))
print("نیم‌فاصله به فاصله :", flip_rate(serve_v2, X_te, lambda t: t.replace(ZWNJ, " ")), "از", len(X_te))
print("افزودنِ سلام       :", flip_rate(serve_v2, X_te, lambda t: "با سلام و احترام " + t), "از", len(X_te))
صفحه‌کلیدِ عربی    : 0 از 193
نیم‌فاصله به فاصله : 0 از 193
افزودنِ سلام       : 2 از 193

هفتاد به صفر، با یک خط. ولی حالا سه عددِ دیگر را صادقانه بخوانیم:

«نیم‌فاصله به فاصله: صفر» — و این صفر پیش از رفعِ باگ هم صفر بود. یعنی این ناوردایی را normalise تأمین نکرده؛ جای دیگری تأمین شده. CountVectorizer هنگامِ توکن‌سازی روی نیم‌فاصله می‌شکند، پس می‌کند و می کند از همان اول یک چیز بودند. تستی که به دلیلی سبز است که تو نمی‌دانی، شاهد نیست. اگر فردا کسی الگوی توکن‌سازی را عوض کند، این صفر بی‌سروصدا بزرگ می‌شود — و حالا که می‌دانی چرا سبز است، آن روز می‌فهمی.

«افزودنِ سلام: ۲ از ۱۹۳» — و این را رفع نکردیم. آن دو تیکت آن‌قدر بی‌نشانه‌اند که سه کلمهٔ تعارف تصمیم را جابه‌جا می‌کند. این باگ نیست، حساسیت است. تصمیمش هم فنی نیست: اگر جابه‌جا شدنِ جوابِ دو تیکت از هر دویست‌تا برایت گران است، باید کلمه‌های تعارف را حذف کنی؛ اگر نه، این عدد را ثبت کن و بگذر.

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

۵. خانوادهٔ سوم: مدل واقعاً چیزی یاد می‌گیرد؟#

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

نکتهٔ ظریفش این است: برچسبِ تصادفی را به هر متنِ یکتا می‌دهیم، نه به هر ردیف. یعنی دو کپیِ یک تیکت همچنان برچسبِ یکسان می‌گیرند — دقیقاً مثلِ دادهٔ واقعی.

import random

rng = random.Random(0)
unique_texts = sorted(set(texts))                  # sorted تا نتیجه بینِ دو اجرا یکی بماند
fake_label = {t: rng.choice(LABELS) for t in unique_texts}
y_fake = [fake_label[t] for t in texts]

F_tr, F_te, g_tr, g_te = train_test_split(
    texts, y_fake, test_size=0.25, random_state=0, stratify=y_fake)
print("برچسبِ تصادفی، split معمولی :", round(build().fit(F_tr, g_tr).score(F_te, g_te), 4))

u_fake = [fake_label[t] for t in unique_texts]
U_tr, U_te, v_tr, v_te = train_test_split(
    unique_texts, u_fake, test_size=0.25, random_state=0, stratify=u_fake)
print("برچسبِ تصادفی، بدونِ متنِ تکراری:", round(build().fit(U_tr, v_tr).score(U_te, v_te), 4))
print("حدسِ تصادفی با چهار برچسب  :", 0.25)
print("متنِ یکتا:", len(unique_texts), "· مجموعهٔ آزمونِ خطِ دوم:", len(U_te), "ردیف")
برچسبِ تصادفی، split معمولی : 0.5803
برچسبِ تصادفی، بدونِ متنِ تکراری: 0.2923
حدسِ تصادفی با چهار برچسب  : 0.25
متنِ یکتا: 520 · مجموعهٔ آزمونِ خطِ دوم: 130 ردیف

۰٫۵۸۰۳ روی داده‌ای که هیچ الگویی داخلش نیست.

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

و خطِ دوم نشان می‌دهد ایراد از مدل نیست: همان مدل، وقتی متنِ تکراری بینِ آموزش و تست نباشد، ۰٫۲۹۲۳ می‌گیرد. این عدد دقیقاً ۰٫۲۵ نیست و نباید هم باشد: خطِ آخر می‌گوید مجموعهٔ آزمونِ آن حالت فقط ۱۳۰ ردیف است، و نوسانِ طبیعیِ چنین نمونه‌ای حدودِ چهار واحد است. ۰٫۲۹ یعنی «هیچ»؛ ۰٫۵۸ یعنی «یک جای کار می‌لنگد».

و آن ۵۲۰ متنِ یکتا در برابرِ ۷۶۹ ردیف، همان مدرکِ جرمِ بخشِ بعد است: بیش از دویست ردیفِ این پیکره تکرارِ متنی‌اند که جای دیگری هم هست.

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

۶. خانوادهٔ چهارم: تستی که نشتی را می‌گیرد#

بعد از آن ۰٫۵۸، دنبالِ متهم می‌گردیم. و تستش یک خط است:

def test_no_train_test_overlap(train, test):
    shared = set(train) & set(test)
    assert not shared, f"{len(shared)} متن هم در آموزش است هم در تست"


try:
    test_no_train_test_overlap(X_tr, X_te)
except AssertionError as exc:
    print("AssertionError:", exc)
AssertionError: 83 متن هم در آموزش است هم در تست

هشتاد و سه متن از ۱۹۳ متنِ تست، عیناً در آموزش هم بوده‌اند. چون کاربرِ واقعی وقتی جواب نمی‌گیرد همان تیکت را دوباره می‌فرستد، و train_test_split هیچ نمی‌داند که این دو ردیف یکی‌اند.

حالا هزینه‌اش را با پنج seed می‌سنجیم:

first_seen = {}
for t in TICKETS:
    key = normalise(t["text"])
    first_seen.setdefault(key, t["label"])
uniq_texts = sorted(first_seen)
uniq_labels = [first_seen[t] for t in uniq_texts]

print(f"  {'seed':<6}{'split معمولی':>14}{'بدونِ تکرار':>14}")
naive_scores, honest_scores = [], []
for seed in range(5):
    a_tr, a_te, c_tr, c_te = train_test_split(
        texts, labels, test_size=0.25, random_state=seed, stratify=labels)
    naive = build().fit(a_tr, c_tr).score(a_te, c_te)
    u_tr, u_te, w_tr, w_te = train_test_split(
        uniq_texts, uniq_labels, test_size=0.25, random_state=seed, stratify=uniq_labels)
    honest = build().fit(u_tr, w_tr).score(u_te, w_te)
    naive_scores.append(naive)
    honest_scores.append(honest)
    print(f"  {seed:<6}{naive:>14.4f}{honest:>14.4f}")

mean_naive = sum(naive_scores) / len(naive_scores)
mean_honest = sum(honest_scores) / len(honest_scores)
print(f"  {'میانگین':<6}{mean_naive:>13.4f}{mean_honest:>14.4f}")
print(f"فاصله: {100 * (mean_naive - mean_honest):.1f} واحد")
print(f"پراکندگیِ ستونِ راست: {min(honest_scores):.4f} تا {max(honest_scores):.4f}")
  seed    split معمولی   بدونِ تکرار
  0             0.9016        0.8308
  1             0.9223        0.8231
  2             0.8860        0.8077
  3             0.9275        0.8385
  4             0.8446        0.8231
  میانگین       0.8964        0.8246
فاصله: 7.2 واحد
پراکندگیِ ستونِ راست: 0.8077 تا 0.8385

آن 0.9016 فصلِ اول، ۷٫۲ واحد بالاتر از واقعیت بود.

و ستونِ چپ چیزِ دومی هم می‌گوید که به‌اندازهٔ خودِ نشتی مهم است: از ۰٫۸۴۴۶ تا ۰٫۹۲۷۵ می‌رود — هشت واحد نوسان، فقط از عوض کردنِ seed. یعنی اگر دو مدل را با یک seed مقایسه کنی و یکی سه واحد جلو بزند، هیچ چیزی ثابت نکرده‌ای.

🔧 اگر کار نکرد: اگر AssertionError: 83 متن هم در آموزش است هم در تست را ندیدی و به‌جایش هیچ خروجی‌ای نگرفتی، یعنی assert سبز شده — و آن بدترین حالت است، نه بهترین. یعنی X_tr و X_te را از جایی گرفته‌ای که قبلاً یکتاسازی شده. برای دیدنِ خودِ پدیده، عمداً از texts استفاده کن که هنوز تکرارها را دارد.

🤖 از دستیارت بپرس: «حذفِ ردیفِ تکراری تنها راهِ رفعِ این نشتی نیست — GroupShuffleSplit چه می‌کند و کِی از حذفِ تکرار بهتر است؟» و بعد خودت این را بپرس: «اگر تیکت‌های یک کاربر خیلی شبیهِ هم باشند ولی عیناً یکی نباشند، assertِ من چه چیزی را از دست می‌دهد؟» — جوابش، تفاوتِ بینِ «متنِ یکسان» و «گروهِ یکسان» است.

۷. چهار خانواده، در یک جدول#

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

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

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

کلمه تلفظ به حروف فارسی یعنی چه
invariance test این‌ورینس تست تستِ اینکه تغییرِ بی‌ربطِ ورودی جواب را عوض نکند
training-serving skew ترینینگ-سروینگ اسکیو تفاوتِ پیش‌پردازشِ زمانِ آموزش با زمانِ سرویس
shuffled-label test شافلد لیبل تست آموزش روی برچسبِ تصادفی برای کشفِ حفظ‌کردن
chance level چنس لول دقتی که حدسِ کور می‌گیرد
duplicate leakage دوپلیکیت لیکیج نشتی از ردیفی که هم در آموزش است هم در تست

تمرین‌ها

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

در فصل بعد#

سه فصل است هر تابعی که نوشتیم، ورودی‌اش را بی‌چون‌وچرا قبول کرده. normalise(None) چه می‌کند؟ usable روی ردیفی که اصلاً ستونِ label ندارد چه می‌کند؟

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

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

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