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

فصل ۹ از ۹

پیشرفت ترم
۰٪

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

پروژه: یک نوت‌بوکِ یادگیری ماشین را بسته کن

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

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

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

عددِ پایانی این است: ۹ فایل، ۲۰۶ خط، ۱۰ تستِ سبز، و دقتِ 0.8154 که در نوت‌بوک و در مفسرِ تازه مو به مو یکی است. و یک چیزِ اضافه که کمتر جایی می‌بینی: README خودش تست می‌شود — هر دستوری که داخلش نوشته‌ایم استخراج و اجرا می‌شود، و هر پنج‌تا با کدِ خروجِ صفر برمی‌گردند.

یک جعبهٔ تحویل با فهرستِ محتویاتِ حکاکی‌شده روی درش

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

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

قبل از شروع#

هر هشت فصلِ قبل. هیچ چیزِ تازه‌ای اینجا نیست جز چیدنشان کنارِ هم.

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

اجرا زمانِ تقریبی
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('VERSION = "1.0.0"\n', encoding="utf-8")

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

_SPACES = re.compile(r"\s+")


def normalise(text: str) -> str:
    if not isinstance(text, str):
        raise TypeError(f"normalise: متن باید str باشد، {type(text).__name__} گرفت")
    return _SPACES.sub(" ", text.replace("ي", "ی").replace("ك", "ک")).strip()
''', encoding="utf-8")

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

from tickets.text import normalise

DATA = Path(__file__).resolve().parent.parent / "tickets.jsonl"
LABELS = ["حساب", "پرداخت", "فنی", "ارسال"]


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 isinstance(r.get("text"), str)
            and r["text"].strip() and r.get("label") in LABELS]


def deduplicate(rows):
    # ردیفِ تکراری بینِ آموزش و تست، دقت را بالاتر از واقعیت نشان می‌دهد (فصل ۳)
    seen, kept = set(), []
    for r in rows:
        key = normalise(r["text"])
        if key not in seen:
            seen.add(key)
            kept.append(r)
    return kept
''', 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.text import normalise


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


def train_and_score(rows, seed=0):
    X = [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)


def predict(model, text):
    return model.predict([normalise(text)])[0]
''', encoding="utf-8")

Path("tickets/cli.py").write_text(r'''import sys

import joblib

from tickets import VERSION, data, model

USAGE = "usage: python -m tickets [check | train | predict <text>]"


def main(argv):
    if not argv or argv[0] not in ("check", "train", "predict"):
        print(USAGE)
        return 2
    command = argv[0]
    if command == "check":
        rows = data.load_tickets()
        kept = data.usable(rows)
        unique = data.deduplicate(kept)
        print(f"version={VERSION} rows={len(rows)} usable={len(kept)} unique={len(unique)}")
        return 0
    if command == "train":
        rows = data.deduplicate(data.usable(data.load_tickets()))
        fitted, score = model.train_and_score(rows)
        joblib.dump(fitted, "model.joblib")
        print(f"trained rows={len(rows)} accuracy={score:.4f}")
        return 0
    if len(argv) < 2:
        print(USAGE)
        return 2
    print(model.predict(joblib.load("model.joblib"), argv[1]))
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))
''', encoding="utf-8")

Path("tickets/__main__.py").write_text(
    "import sys\n\nfrom tickets.cli import main\n\nsys.exit(main(sys.argv[1:]))\n",
    encoding="utf-8")

print(sorted(p.name for p in Path("tickets").glob("*.py")))
['__init__.py', '__main__.py', 'cli.py', 'data.py', 'model.py', 'text.py']

سه تصمیم که از فصل‌های قبل آمده‌اند:

۱) deduplicate در data.py نشسته، نه در model.py. آن assert فصلِ ۳ نشان داد نشتیِ ردیفِ تکراری هفت واحد دقت را باد می‌کند؛ حالا رفعش بخشی از خودِ بارگذاریِ داده است، نه کاری که باید یادت بماند انجامش بدهی.

۲) normalise روی ورودیِ غیررشته‌ای TypeError می‌دهد، نه assert. فصلِ ۵ گفت assert با پرچمِ -O حذف می‌شود؛ این‌یکی اعتبارسنجیِ ورودی است و باید همیشه باشد.

۳) cli.py عدد برمی‌گرداند و __main__.py آن را به sys.exit می‌دهد. یعنی هر دستور یک کدِ خروج دارد: صفر یعنی موفق. هر ابزارِ خودکاری که فردا این را صدا بزند، فقط همین عدد را می‌فهمد.

۲. مجموعهٔ تست، در یک فایل#

ده تست، از هر چهار خانوادهٔ فصلِ ۳ به‌علاوهٔ تست‌های معمولیِ فصلِ ۲:

Path("tests.py").write_text(r'''import sys

from tickets import data, model
from tickets.text import normalise


def run_tests(namespace):
    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}"))
    for name, why in failed:
        print(f"  x {name} - {why}")
    print(f"{len(names) - len(failed)} از {len(names)} تست سبز")
    return len(failed)


def test_normalise_arabic():
    assert normalise("كارت بانكي") == "کارت بانکی"


def test_normalise_spaces():
    assert normalise("  رمز    عبور ") == "رمز عبور"


def test_normalise_rejects_none():
    try:
        normalise(None)
    except TypeError:
        return
    raise AssertionError("normalise باید روی None خطا بدهد")


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


def test_usable_drops_unknown_label():
    rows = [{"text": "سلام", "label": "نامشخص"}, {"text": "سلام", "label": "فنی"}]
    assert len(data.usable(rows)) == 1


def test_deduplicate_removes_repeats():
    rows = [{"text": "كارت", "label": "پرداخت"}, {"text": "کارت", "label": "پرداخت"}]
    assert len(data.deduplicate(rows)) == 1, "دو نوشتارِ یک متن باید یکی شمرده شوند"


def test_no_train_test_overlap():
    rows = data.deduplicate(data.usable(data.load_tickets()))
    texts = [normalise(r["text"]) for r in rows]
    assert len(set(texts)) == len(texts), "متنِ تکراری در دادهٔ آموزش مانده"


def test_proba_shape_and_sum():
    rows = data.deduplicate(data.usable(data.load_tickets()))
    fitted, _ = model.train_and_score(rows)
    proba = fitted.predict_proba(["رمز عبورم کار نمی‌کند"])
    assert proba.shape == (1, 4), f"شکلِ غیرمنتظره: {proba.shape}"
    assert abs(proba.sum() - 1.0) < 1e-9


def test_invariant_to_keyboard():
    rows = data.deduplicate(data.usable(data.load_tickets()))
    fitted, _ = model.train_and_score(rows)
    persian = model.predict(fitted, "کارت بانکی من کار نمی‌کند")
    arabic = model.predict(fitted, "كارت بانكي من كار نمي‌كند")
    assert persian == arabic, f"صفحه‌کلید جواب را عوض کرد: {persian} در برابرِ {arabic}"


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


if __name__ == "__main__":
    sys.exit(1 if run_tests(dict(globals())) else 0)
''', encoding="utf-8")

import subprocess
import sys

done = subprocess.run([sys.executable, "tests.py"], capture_output=True, text=True,
                      encoding="utf-8")
print(done.stdout.strip())
print("کدِ خروج:", done.returncode)
10 از 10 تست سبز
کدِ خروج: 0

به test_invariant_to_keyboard و test_no_train_test_overlap نگاه کن: دو درسِ گران‌قیمتِ فصلِ ۳، حالا به‌شکلِ دو تستِ ده‌خطی. روزی که کسی normalise را از مسیرِ پیش‌بینی بردارد، اولی قرمز می‌شود. روزی که کسی deduplicate را «برای سرعت» حذف کند، دومی.

و آن sys.exit(1 if ... else 0) مهم‌ترین خطِ فایل است. بدونش، دروازهٔ خودکارِ ترمِ ۶ نمی‌فهمد تستی شکسته — چون هیچ ماشینی متنِ فارسیِ خروجی را نمی‌خواند، فقط کدِ خروج را.

۳. سنجاقِ نسخه#

import importlib.metadata

PINNED = ["scikit-learn", "numpy", "joblib"]
lines = [f"{name}=={importlib.metadata.version(name)}" for name in PINNED]
Path("requirements.txt").write_text("\n".join(lines) + "\n", encoding="utf-8")
print(Path("requirements.txt").read_text(encoding="utf-8").strip())
scikit-learn==1.7.2
numpy==2.4.6
joblib==1.5.3

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

و بدانی چه چیزی را تضمین نمی‌کند: سنجاقِ نسخه محیط را مشابه می‌کند، نه یکسان. نسخهٔ خودِ پایتون، سیستم‌عامل و کتابخانه‌های سطحِ پایین هنوز آزادند. و ترمِ ۳ هم تضمینش نمی‌کند. آن ترم محیط را ثبت می‌کند تا فردا بدانی چه چیزی عوض شده؛ خودِ آن فصل صریح می‌گوید که بازتولیدپذیریِ کامل تقریباً هیچ‌وقت به‌دست نمی‌آید. این فایل هشتاد درصدِ فایده را با یک فایل می‌دهد؛ بقیه‌اش کارِ Dockerfile و فایلِ قفل است که ترمِ ۴ فصلِ ۱ می‌سازدشان.

۴. READMEی که تست می‌شود#

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

FENCE = "`" * 3          # سه بک‌تیک، در کد ساخته می‌شود تا با خودِ این فایل تداخل نکند

README = "\n".join([
    "# tickets",
    "",
    "دسته‌بندیِ تیکتِ پشتیبانیِ فارسی.",
    "",
    "## نصب",
    "",
    FENCE + "bash",
    "pip install -r requirements.txt",
    FENCE,
    "",
    "## اجرا",
    "",
    FENCE + "bash",
    "python -m tickets check",
    "python -m tickets train",
    'python -m tickets predict "رمز عبورم را فراموش کردم"',
    "python tests.py",
    FENCE,
    "",
])
Path("README.md").write_text(README, encoding="utf-8")


def commands_in(readme):
    inside, found = False, []
    for line in readme.splitlines():
        if line.startswith(FENCE):
            inside = line.strip() == FENCE + "bash"
            continue
        if inside and line.strip():
            found.append(line.strip())
    return found


commands = commands_in(Path("README.md").read_text(encoding="utf-8"))
print("دستورهای پیداشده در README:", len(commands))
for c in commands:
    print("  ", c)
دستورهای پیداشده در README: 5
   pip install -r requirements.txt
   python -m tickets check
   python -m tickets train
   python -m tickets predict "رمز عبورم را فراموش کردم"
   python tests.py
import shlex

results = []
for command in commands:
    if command.startswith("pip "):
        results.append((command, "رد شد (نصب لازم نیست)", 0))
        continue
    parts = shlex.split(command)
    parts = [sys.executable if p == "python" else p for p in parts]
    done = subprocess.run(parts, capture_output=True, text=True, encoding="utf-8")
    printed = (done.stdout.strip() or done.stderr.strip() or "(بدونِ خروجی)").splitlines()
    results.append((command, printed[-1], done.returncode))

for command, output, code in results:
    print(f"  [{code}] {command}")
    print(f"        → {output}")
print("همهٔ دستورها موفق؟", all(code == 0 for _, _, code in results))
  [0] pip install -r requirements.txt
        → رد شد (نصب لازم نیست)
  [0] python -m tickets check
        → version=1.0.0 rows=769 usable=769 unique=520
  [0] python -m tickets train
        → trained rows=520 accuracy=0.8154
  [0] python -m tickets predict "رمز عبورم را فراموش کردم"
        → حساب
  [0] python tests.py
        → 10 از 10 تست سبز
همهٔ دستورها موفق؟ True

پنج دستور، هر پنج‌تا با کدِ خروجِ صفر.

این سی خط کد، مستنداتت را از یک وعده به یک آزمون تبدیل می‌کند. فردا که کسی نامِ دستور را عوض کند و یادش برود README را به‌روز کند، همین قطعه قرمز می‌شود. مستندی که اجرا نمی‌شود، حدس است.

📏 اندازه بگیر: با چه چیزی مقایسه شد؟ با هیچ — این یک دروازه است، نه یک بهبود. جوابش دودویی است: یا هر پنج دستور کار می‌کنند یا نه. روی کدام داده؟ همان پیکرهٔ ۷۶۹تایی. با چند seed؟ یکی، چون دوباره ادعای «بهتر شد» نداریم؛ ادعا این است که «همان می‌شود»، و برای آن یک seed کافی است — دقیقاً مثلِ فصلِ ۱.

۵. مفسرِ تازه، همان عدد#

from tickets import data, model

rows = data.deduplicate(data.usable(data.load_tickets()))
fitted, score = model.train_and_score(rows)
print(f"دقت در نوت‌بوک        : {score:.4f}")

done = subprocess.run([sys.executable, "-m", "tickets", "train"],
                      capture_output=True, text=True, encoding="utf-8")
from_cli = float(done.stdout.strip().split("accuracy=")[1])
print(f"دقت از مفسرِ تازه     : {from_cli:.4f}")
print("مو به مو یکی؟", f"{score:.4f}" == f"{from_cli:.4f}")
دقت در نوت‌بوک        : 0.8154
دقت از مفسرِ تازه     : 0.8154
مو به مو یکی؟ True

همان کاری که در فصلِ ۱ کردیم، این بار با کلِ بسته — و این بار عدد صادقانه است.

۰٫۸۱۵۴ در برابرِ ۰٫۹۰۱۶ فصلِ اول. آن عدد نشتی داشت؛ این‌یکی روی دادهٔ یکتاشده است و در همان بازهٔ ۰٫۸۰۸ تا ۰٫۸۳۹ می‌افتد که فصلِ ۳ با پنج seed اندازه گرفت. یعنی نتیجه با آنچه قبلاً سنجیدیم می‌خواند — و همین «خواندن با چیزی که قبلاً سنجیده‌ای» تنها راهِ اطمینان است.

چک کن: اگر عددِ نوت‌بوک و عددِ مفسرِ تازه یکی نشد، همان لحظه بایست. یعنی چیزی در نوت‌بوکت هست که در بسته نیست — معمولاً یک متغیر یا یک seed که فقط در حافظه زندگی می‌کند. این دقیقاً همان چیزی است که فردا کسِ دیگری را زمین می‌زند.

۶. سیاههٔ تحویل#

import time

start = time.perf_counter()
subprocess.run([sys.executable, "tests.py"], capture_output=True, text=True, encoding="utf-8")
tests_time = time.perf_counter() - start

files = sorted(Path(".").rglob("*.py")) + [Path("README.md"), Path("requirements.txt")]
total_lines = sum(len(p.read_text(encoding="utf-8").splitlines()) for p in files)
print(f"فایل‌های بسته      : {len(files)}")
print(f"مجموعِ خطوط        : {total_lines}")
print(f"زمانِ مجموعهٔ تست   : {tests_time:.1f} ثانیه")
فایل‌های بسته      : 9
مجموعِ خطوط        : 206
زمانِ مجموعهٔ تست   : 2.1 ثانیه

دویست و شش خط. این همان چیزی است که یک ترم کار روی «کدی که می‌شود به آن تکیه کرد» تولید می‌کند.

و این سیاهه‌ای است که از حالا به بعد قبل از هر تحویلی می‌روی:

سؤال کجای این بسته جوابش است
کسِ دیگری چطور اجرایش کند؟ README.md — و دستورهایش تست می‌شوند
از کجا بدانم سالم است؟ python tests.py با کدِ خروج
چه نسخه‌هایی لازم است؟ requirements.txt، خوانده‌شده از محیطِ واقعی
عدد از کجا آمده؟ python -m tickets train، بازتولیدپذیر
دادهٔ خراب چه می‌شود؟ usable و TypeError سرِ مرز
نشتی چطور جلویش گرفته شده؟ deduplicate + test_no_train_test_overlap

🔧 اگر کار نکرد: اگر python -m tickets با No module named tickets شکست، مفسر از پوشه‌ای دیگر اجرا شده. python -m پوشهٔ جاری را به sys.path اضافه می‌کند، پس باید دقیقاً در پوشهٔ بالای tickets/ باشی. با print(Path.cwd()) ببین کجایی. و اگر predict با FileNotFoundError: model.joblib شکست، train را قبلش نزده‌ای — ترتیبِ دستورهای README هم برای همین همان است.

🤖 از دستیارت بپرس: «pyproject.toml چه چیزی به این بسته اضافه می‌کند که requirements.txt ندارد؟» جوابِ درست شاملِ نصب‌پذیر شدن، نامِ رسمیِ بسته و جدا شدنِ وابستگیِ توسعه از اجراست. بعد خودت این را بپرس: «اگر بسته‌ام را کسی pip install کند، مسیرِ tickets.jsonl که با __file__ حساب شده کجا می‌افتد؟» — جوابش تلخ است و دلیلِ اینکه داده هرگز داخلِ بسته نمی‌رود؛ ترمِ ۲ همین را کامل می‌کند.

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

کلمه تلفظ به حروف فارسی یعنی چه
exit code اگزیت کد عددی که برنامه موقعِ پایان می‌دهد؛ صفر یعنی موفق
__main__.py مِین فایلی که python -m <بسته> اجرایش می‌کند
pinning پینینگ ثبتِ نسخهٔ دقیقِ وابستگی‌ها
executable documentation اگزکیوتبل داکیومنتیشن مستندی که دستورهایش واقعاً اجرا و بررسی می‌شوند
regression suite ریگرشن سوئیت مجموعهٔ تستی که جلوی برگشتِ باگ‌های قدیمی را می‌گیرد
delivery checklist دلیوری چک‌لیست سیاههٔ ثابتی که پیش از هر تحویل مرور می‌شود

تمرین‌ها

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

در فصل بعد#

ترمِ ۱ تمام شد. حالا کدی داری که تست دارد، قرارداد دارد، پروفایل شده، و بازتولیدپذیر است.

ولی همهٔ این‌ها فرض کرده‌اند داده‌ات درست است. ترمِ ۲ همان فرض را برمی‌دارد: مسیرِ داده از منبع تا ویژگی، قالبِ ذخیره با عدد، اعتبارسنجی و اسکیما، نسخه‌بندیِ داده، و قاعدهٔ سختش: هیچ تبدیلی روی داده بدونِ ثبتِ منشأ انجام نمی‌شود.

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

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