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

فصل ۱ از ۸

پیشرفت ترم
۰٪

ترم ۳ · آزمایش، ردیابی، بازتولید

آزمایش، به‌عنوان واحدِ کار

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

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

چهار تنظیم را امتحان می‌کنی، چهار عدد می‌گیری، و سه هفته بعد هیچ‌کدام از آن چهار عدد به هیچ چیزی وصل نیست. این فصل جلوی همان را می‌گیرد.

و در راهش یک عددِ ناخوشایند درمی‌آید: یک پیکربندیِ ثابت، فقط با عوض کردنِ seed، F1 بینِ ۰٫۵۱۰ و ۰٫۵۴۷ می‌دهد — دامنهٔ ۰٫۰۳۷. در همان جدول، اختلافِ دو روشِ متوازن‌سازی ۰٫۰۰۷ است. یعنی تفاوتی که به‌عنوان «نتیجه» گزارشش می‌کردی، پنج برابر کوچک‌تر از نوسانی است که هیچ ربطی به تنظیماتت ندارد.

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

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

  • یک آزمایش را به‌شکلِ تابعی بنویسی که فقط یک ورودی دارد: پیکربندی
  • شناسهٔ اجرا بسازی که با ترتیبِ کلیدها عوض نشود ولی با seed عوض بشود
  • فرقِ «اجرا» و «نتیجه» را بگویی و بگویی هرکدام چه چیزی را باید نگه دارند
  • بگویی یک تفاوتِ عددی اصلاً به اندازهٔ نوسانِ seed هست یا نه

قبل از شروع#

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

دادهٔ این ترم: همان جنسِ تیکتِ پشتیبانیِ فارسی، با یک تفاوتِ عمدی — اینجا زمان معنا دارد. پیکره هشت ماه طول می‌کشد و در ماهِ ششم اتفاقی می‌افتد. فصلِ ۷ کاملاً روی همان ساخته شده؛ تا آنجا فقط بدان که ستونِ created_at تزیینی نیست.

سلولِ راه‌اندازی چهار فهرست به‌ت می‌دهد: X متنِ تیکت، Y برچسبِ تشدید، G شناسهٔ مشتری، T زمانِ ثبت — و DATA که همهٔ ستون‌ها را دارد.

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

اجرا زمانِ تقریبی
کلِ نوت‌بوک روی CPU کمتر از یک دقیقه
GPU لازم نیست

۱. کاری که همه می‌کنند#

import random

from sklearn.feature_extraction.text import CountVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import f1_score

idx = list(range(len(Y)))
random.Random(0).shuffle(idx)
cut = int(0.25 * len(Y))
tr, te = idx[cut:], idx[:cut]

vec = CountVectorizer(min_df=1)
model = LogisticRegression(max_iter=2000, random_state=0)
model.fit(vec.fit_transform([X[i] for i in tr]), [Y[i] for i in tr])
pred = model.predict(vec.transform([X[i] for i in te]))
print("F1:", round(f1_score([Y[i] for i in te], pred, zero_division=0), 4))
F1: 0.4023

۰٫۴۰۲۳. حالا min_df را ۳ کن و دوباره اجرا کن. بعد max_iter را کم کن. بعد یک ویژگی اضافه کن. هر بار یک عدد می‌گیری و هر بار همان سلول را می‌نویسی رویش.

سه هفته بعد از تو می‌پرسند: عددِ ۰٫۵۴ که در جلسه گفتی، مالِ کدام تنظیم بود؟

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

۲. پیکربندی، یک چیزِ واحد است#

قدمِ اول این است: همهٔ تنظیم‌ها را از دلِ کد دربیاور و در یک dict بگذار. بعد یک تابع بنویس که تنها ورودی‌اش همان dict باشد.

from sklearn.metrics import accuracy_score, precision_score, recall_score

DEFAULTS = {"features": "text", "ngram": 1, "min_df": 1, "C": 1.0,
            "balance": "none", "split": "random", "seed": 0}


def texts_for(cfg):
    """پیکربندی می‌گوید مدل چه چیزی را ببیند: فقط متن، فقط فراداده، یا هر دو."""
    meta = [f"cust_{r['customer_id']} ch_{r['channel']}"
            f" cat_{r['category'].replace(' ', '_')}" for r in DATA]
    if cfg["features"] == "text":
        return X
    if cfg["features"] == "meta":
        return meta
    return [f"{a} {b}" for a, b in zip(X, meta)]


def random_split(n, seed, frac=0.25):
    idx = list(range(n))
    random.Random(seed).shuffle(idx)
    cut = int(frac * n)
    return idx[cut:], idx[:cut]


def group_split(groups, seed, frac=0.25):
    """هر مشتری کاملاً یا در آموزش است یا در آزمون — همان تقسیمِ ترمِ ۲."""
    names = sorted(set(groups))
    random.Random(seed).shuffle(names)
    held, held_rows, target = set(), 0, frac * len(groups)
    for g in names:
        if held_rows >= target:
            break
        held.add(g)
        held_rows += sum(1 for x in groups if x == g)
    return ([i for i, g in enumerate(groups) if g not in held],
            [i for i, g in enumerate(groups) if g in held])


def oversample(idx, seed):
    rng = random.Random(seed)
    minor = [i for i in idx if Y[i] == 1]
    major = [i for i in idx if Y[i] == 0]
    out = list(idx) + [rng.choice(minor) for _ in range(len(major) - len(minor))]
    rng.shuffle(out)
    return out


def run(cfg):
    """یک آزمایش: پیکربندی می‌رود تو، متریک می‌آید بیرون. هیچ چیزِ دیگری."""
    cfg = DEFAULTS | cfg
    texts = texts_for(cfg)
    if cfg["split"] == "group":
        tr, te = group_split(G, cfg["seed"])
    else:
        tr, te = random_split(len(Y), cfg["seed"])
    if cfg["balance"] == "oversample":
        tr = oversample(tr, cfg["seed"])
    vec = CountVectorizer(min_df=cfg["min_df"], ngram_range=(1, cfg["ngram"]))
    model = LogisticRegression(
        max_iter=2000, C=cfg["C"], random_state=cfg["seed"],
        class_weight="balanced" if cfg["balance"] == "weight" else None)
    model.fit(vec.fit_transform([texts[i] for i in tr]), [Y[i] for i in tr])
    pred = model.predict(vec.transform([texts[i] for i in te]))
    gold = [Y[i] for i in te]
    return {"f1": round(f1_score(gold, pred, zero_division=0), 4),
            "precision": round(precision_score(gold, pred, zero_division=0), 4),
            "recall": round(recall_score(gold, pred, zero_division=0), 4),
            "accuracy": round(accuracy_score(gold, pred), 4)}


print(run({}))
{'f1': 0.4023, 'precision': 0.5738, 'recall': 0.3097, 'accuracy': 0.7387}

همان ۰٫۴۰۲۳، این بار از یک تابع. ظاهراً هیچ اتفاقی نیفتاده. ولی سه چیز عوض شده:

  • ۱) تنظیمات حالا یک شیء‌اند که می‌شود ذخیره‌اش کرد، فرستادش، و با یکی دیگر مقایسه‌اش کرد.
  • ۲) DEFAULTS | cfg یعنی هر پیکربندیِ ناقص خودش کامل می‌شود، و پیکربندیِ کامل همان چیزی است که ثبت می‌شود — نه آنچه تو تایپ کردی.
  • ۳) seed یک تنظیم است، نه یک جزئیاتِ اجرایی. این تصمیمِ کوچک نیمی از فصلِ ۵ را ممکن می‌کند.

همین تابع در سلولِ راه‌اندازیِ ترم هم هست، پس فصل‌های بعد دوباره تعریفش نمی‌کنند و فقط صدایش می‌زنند. group_split هم همان تقسیمِ گروهیِ ترمِ ۲ است که اینجا یکی از مقدارهای کلیدِ split شده — چون نوعِ تقسیم هم یک تنظیمِ آزمایش است، نه یک تصمیمِ همیشگی.

DEFAULTS | cfg یک اضافهٔ ظاهری نیست. ببین بدونش چه می‌شود:

try:
    bad = {"features": "text", "ngram": 1, "min_df": 1,
           "balance": "none", "split": "random", "seed": 0}   # «C» را جا انداخته‌ایم
    CountVectorizer(min_df=bad["min_df"])
    LogisticRegression(C=bad["C"])
except KeyError as e:
    print(f"{type(e).__name__}: {e}")
print("با DEFAULTS:", (DEFAULTS | bad)["C"])
KeyError: 'C'
با DEFAULTS: 1.0

🔧 اگر کار نکرد: KeyError: 'C' یعنی پیکربندی‌ات کلیدی را ندارد که تابع انتظارش را دارد. وسوسه‌ای که باید جلویش را بگیری این است که به‌جای رفعِ ریشه، cfg.get("C", 1.0) بنویسی. آن‌وقت مقدارِ پیش‌فرض داخلِ بدنهٔ تابع پنهان می‌شود و در رکوردِ آزمایش هیچ‌جا نوشته نمی‌شود — و سه ماه بعد که پیش‌فرض را عوض کنی، همهٔ رکوردهای قدیمی‌ات بی‌سروصدا معنایشان عوض می‌شود. یک DEFAULTS بیرونی این را ندارد: هر اجرا پیکربندیِ کامل را ثبت می‌کند.

۳. چهار آزمایش، یک تابع#

CANDIDATES = [
    {},
    {"balance": "oversample"},
    {"balance": "weight"},
    {"features": "meta", "balance": "oversample"},
]
print(f"{'پیکربندی':<34}{'F1':>8}{'precision':>11}{'recall':>9}")
for cfg in CANDIDATES:
    r = run(cfg)
    label = "پیش‌فرض" if not cfg else " · ".join(f"{k}={v}" for k, v in cfg.items())
    print(f"{label:<34}{r['f1']:>8.4f}{r['precision']:>11.4f}{r['recall']:>9.4f}")
پیکربندی                                F1  precision   recall
پیش‌فرض                             0.4023     0.5738   0.3097
balance=oversample                  0.5469     0.5076   0.5929
balance=weight                      0.5397     0.4892   0.6018
features=meta · balance=oversample  0.5690     0.5546   0.5841

چهار خط کد به‌جای چهار بار دست‌کاریِ یک سلول. و حالا بردن یا نبردنِ یک ایده، یک ردیفِ فهرست است — نه بیست دقیقه ویرایش.

نتیجهٔ ظاهری: balance=oversample از balance=weight بهتر است (۰٫۵۴۶۹ در برابر ۰٫۵۳۹۷). این جمله را نگه دار؛ بخشِ بعد نابودش می‌کند.

۴. اجرا در برابرِ نتیجه#

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

first = run({"balance": "oversample"})
second = run({"balance": "oversample"})
print("دو اجرا با پیکربندیِ یکسان:", first["f1"], "و", second["f1"],
      "→ برابر:", first == second)

spread = [run({"balance": "oversample", "seed": s})["f1"] for s in range(5)]
print("همان پیکربندی با پنج seed :", spread)
print(f"دامنه: {max(spread) - min(spread):.4f}")
دو اجرا با پیکربندیِ یکسان: 0.5469 و 0.5469 → برابر: True
همان پیکربندی با پنج seed : [0.5469, 0.5179, 0.5101, 0.5113, 0.524]
دامنه: 0.0368

خطِ اول خبرِ خوب است: پیکربندیِ یکسان، نتیجهٔ یکسان. تابعِ ما تابع است — با ورودیِ یکسان خروجیِ یکسان می‌دهد. (فصلِ ۳ نشان می‌دهد این خاصیت چقدر شکننده است و کجاها اصلاً به‌دست نمی‌آید.)

خطِ دوم خبرِ بد است. seed فقط ترتیبِ تقسیمِ داده و ترتیبِ کپی‌های فرانمونه را عوض می‌کند. هیچ تنظیمِ معناداری دست نخورده. و با این حال F1 از ۰٫۵۱۰۱ تا ۰٫۵۴۶۹ بالا و پایین می‌رود.

حالا برگرد به جدولِ بخشِ ۳. آنجا oversample را ۰٫۰۰۷۲ بالاتر از weight دیدیم و ممکن بود همان‌جا تصمیم بگیریم. ۰٫۰۰۷۲ در برابرِ دامنهٔ ۰٫۰۳۶۸. آن «برتری» کاملاً داخلِ نوسانی است که با عوض کردنِ یک عددِ بی‌معنا هم به‌دست می‌آید.

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

چک کن: خودت امتحانش کن — spread را برای {"balance": "weight"} هم بگیر. دو دامنه را کنارِ هم بگذار. اگر روی هم افتاده باشند، ادعای «کدام بهتر است» از این داده درنمی‌آید. فصلِ ۵ برای همین یک فصلِ کامل است.

۵. شناسهٔ اجرا#

اگر آزمایش یک پیکربندی است، شناسه‌اش باید از خودِ همان پیکربندی درآید، نه از شمارنده‌ای که تو دستی زیاد می‌کنی. اثرِ انگشتِ محتوا را در ترمِ ۲ برای فایل ساختیم؛ اینجا همان کار را برای یک dict می‌کنیم.

import hashlib
import json


def run_id(cfg):
    """شناسهٔ اجرا = اثرِ انگشتِ پیکربندی. یکسان‌بودنِ شناسه یعنی همان آزمایش."""
    full = DEFAULTS | cfg
    blob = json.dumps(full, sort_keys=True, ensure_ascii=False)
    return hashlib.sha256(blob.encode("utf-8")).hexdigest()[:12]


a = {"balance": "oversample", "C": 1.0}
b = {"C": 1.0, "balance": "oversample"}
print("ترتیبِ کلیدها فرق دارد →", run_id(a) == run_id(b), run_id(a))
print("seed عوض شد          →", run_id(a) == run_id(a | {"seed": 1}),
      run_id(a | {"seed": 1}))
ترتیبِ کلیدها فرق دارد → True a5856660bd1c
seed عوض شد          → False 77a4f0c0a879

sort_keys=True تمامِ ماجراست. بدونِ آن، همان پیکربندی که با ترتیبِ متفاوتِ کلیدها نوشته شده، شناسهٔ متفاوتی می‌گرفت و در جدولِ مقایسه‌ات دو آزمایش به‌نظر می‌رسید که در واقع یکی است.

و seed که عوض شود شناسه عوض می‌شود — که درست است، چون همان‌طور که دیدیم، نتیجه هم عوض می‌شود.

۶. یک اجرا فقط پیکربندی نیست#

پیکربندیِ یکسان + کدِ عوض‌شده = نتیجهٔ متفاوت. پیکربندیِ یکسان + دادهٔ عوض‌شده = نتیجهٔ متفاوت. پس رکوردِ اجرا باید هر سه را داشته باشد.

import inspect

CODE_FP = hashlib.sha256(
    (inspect.getsource(run) + inspect.getsource(texts_for)).encode("utf-8")
).hexdigest()[:12]
DATA_FP = hashlib.sha256(
    "\n".join(f"{t}|{g}|{y}|{x}" for t, g, y, x in zip(T, G, Y, X)).encode("utf-8")
).hexdigest()[:12]


def full_record(cfg):
    """اجرا = پیکربندی + کد + داده. نتیجه = چیزی که از این سه بیرون می‌آید."""
    return {"run_id": run_id(cfg), "config": DEFAULTS | cfg,
            "code_fp": CODE_FP, "data_fp": DATA_FP, "metrics": run(cfg)}


rec = full_record({"balance": "oversample", "seed": 3})
print("run_id :", rec["run_id"])
print("code_fp:", rec["code_fp"], "· data_fp:", rec["data_fp"])
print("metrics:", rec["metrics"])
run_id : 290e13e32ff0
code_fp: 54de5cc1132f · data_fp: 50c6fba97e38
metrics: {'f1': 0.5113, 'precision': 0.4474, 'recall': 0.5965, 'accuracy': 0.6734}

این رکورد یک ادعای قابلِ بررسی است، نه یک یادداشت. اگر ماه‌ها بعد کسی همین run_id را دوباره بزند و به f1 دیگری برسد، یکی از آن دو اثرِ انگشت هم عوض شده و می‌شود دقیقاً گفت کدام: کد، یا داده.

inspect.getsource عمداً انتخاب شده و نه چیزِ پیچیده‌تری: متنِ تابع همان چیزی است که رفتار را تعیین می‌کند. یک فاصلهٔ اضافه هم اثرِ انگشت را عوض می‌کند، که در ظاهر سخت‌گیرانه است ولی در عمل درست: بهتر است بگوید «کد عوض شده» و نبوده باشد، تا اینکه نگوید و بوده باشد.

🤖 از دستیارت بپرس: «چرا json.dumps با sort_keys=True برای ساختنِ شناسه امن است ولی str(dict) نیست؟» بعد این را هم بپرس: «اگر یکی از مقدارهای پیکربندی یک تابع باشد چه اتفاقی می‌افتد؟» — جوابِ دوم به یک قاعدهٔ عملی می‌رسد که تا آخرِ این ترم به‌ش تکیه می‌کنیم: پیکربندی فقط از انواعِ ساده تشکیل می‌شود (عدد، رشته، bool، فهرست)، وگرنه اصلاً قابلِ ثبت نیست.

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

کلمه تلفظ به حروف فارسی یعنی چه
experiment اکسپریمنت یک پیکربندیِ مشخص که اجرا می‌شود و متریک می‌دهد
config کانفیگ همهٔ تنظیم‌های یک آزمایش، به‌شکلِ داده
run ران یک اجرای مشخص: پیکربندی + کد + داده
run id ران آی‌دی شناسهٔ یکتای اجرا، ساخته‌شده از خودِ پیکربندی
code fingerprint کد فینگرپرینت اثرِ انگشتِ متنِ کدی که آزمایش را اجرا کرده
seed variance سید واریانس نوسانِ نتیجه وقتی فقط seed عوض می‌شود

تمرین‌ها

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

در فصل بعد#

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

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

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