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

فصل ۱ از ۹

پیشرفت ترم
۰٪

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

از نوت‌بوک به ماژول

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

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

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

این فصل همان تحلیل را به یک بستهٔ سه‌فایلی تبدیل می‌کند و بعد در یک مفسرِ کاملاً تازه اجرایش می‌کند — مفسری که هیچ‌کدام از متغیرهای تو را ندیده. عددِ خروجی باید مو به مو همان باشد: ۰٫۹۰۱۶ در نوت‌بوک، ۰٫۹۰۱۶ در مفسرِ تازه. و در همان مسیر می‌بینی که همان کد، وقتی کسِ دیگری بدونِ ترتیبِ سلول‌ها اجرایش می‌کند، با NameError می‌میرد.

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

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

  • کدِ یک نوت‌بوک را به ماژول‌هایی با مسئولیتِ روشن تقسیم کنی
  • ماژولِ خودت را import کنی و بدانی پایتون از کجا پیدایش می‌کند
  • بگویی چرا ویرایشِ یک فایل بعد از import هیچ اثری ندارد
  • مرزِ «کد» و «اجرا» را با if __name__ == "__main__" بکشی

قبل از شروع#

از سرنخ ترمِ ۵: فایلِ .py در برابرِ نوت‌بوک، pathlib، و subprocess.run. اینجا هر سه را جدی‌تر به‌کار می‌بریم.

از سرنخ ترمِ ۳: train_test_split، Pipeline و accuracy. مدل در این فصل فقط بهانه است — چیزی که می‌سازیم ساختار است، نه مدل.

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

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

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

اصطلاح چه می‌کند
f"{name:<18}{value:>10.4f}" زبانِ قالب‌بندیِ خروجی، و در تقریباً هر جدولِ این دوره هست: < چپ‌چین، > راست‌چین، عدد پهنای ستون، و .4f یعنی چهار رقمِ اعشار. عرض ثابت است تا ستون‌ها زیرِ هم بیفتند
str.maketrans و str.translate یک جدولِ تبدیل می‌سازد و در یک گذر روی رشته اعمالش می‌کند؛ جایگزینِ چند replace پشتِ سرِ هم
global به تابع می‌گوید «این نام مالِ بیرون است، محلی نساز» — تنها جایی لازم می‌شود که تابعی به یک شمارندهٔ بیرونی مقدار می‌دهد
dict1 | dict2 یک دیکشنریِ تازه از ترکیبِ دوتا؛ هر کلیدِ مشترک از دومی برداشته می‌شود. هیچ‌کدام از دو تای اصلی عوض نمی‌شوند
iter(callable, sentinel) تابعِ بی‌آرگومان را بارها صدا می‌زند تا وقتی جوابش با sentinel برابر شود؛ الگوی خواندنِ یک فایل تکه‌تکه تا رسیدن به انتهایش
اجرا زمانِ تقریبی
CPU (پیش‌فرضِ Colab) کمتر از یک دقیقه

هیچ فصلی از این ترم GPU نمی‌خواهد.

📓 نوت‌بوک: نوت‌بوک این فصل را در 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 clean(text):
    text = text.replace("ي", "ی")
    return re.sub(r"\s+", " ", text).strip()


rows = [t for t in TICKETS if t["text"]]
X = [clean(t["text"]) for t in rows]
y = [t["label"] for t in rows]

X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.25, random_state=0, stratify=y)
model = make_pipeline(CountVectorizer(), LogisticRegression(max_iter=1000))
model.fit(X_train, y_train)
print("نمونه‌ها:", len(X), "| دقت:", round(model.score(X_test, y_test), 4))
نمونه‌ها: 769 | دقت: 0.9016

۰٫۹۰۱۶ روی چهار دستهٔ تیکت. این عدد را نگه دار؛ کلِ ترم به آن برمی‌گردیم و در فصلِ ۳ معلوم می‌شود بخشی از آن دروغ است.

حالا فرض کن کسی از تو می‌خواهد همین را روی داده‌های خودش اجرا کند. دو سلولِ آخر را برایش کپی می‌کنی. ببینیم چه می‌شود — با subprocess یک مفسرِ کاملاً تازه بالا می‌آوریم، مفسری که هیچ‌چیز از نوت‌بوکِ ما نمی‌داند:

import subprocess
import sys
from pathlib import Path

Path("wrong_order.py").write_text(
    "rows = [t for t in TICKETS if t['text']]\n"
    "X = [clean(t['text']) for t in rows]\n"
    "print('نمونه‌ها:', len(X))\n",
    encoding="utf-8")

done = subprocess.run([sys.executable, "wrong_order.py"],
                      capture_output=True, text=True, encoding="utf-8")
print("کدِ خروج:", done.returncode)
print(done.stderr.strip().splitlines()[-1])
کدِ خروج: 1
NameError: name 'TICKETS' is not defined

این خطا احمقانه به‌نظر می‌رسد ولی دقیقاً همان چیزی است که سرِ همه می‌آید. کدِ نوت‌بوک به یک حالتِ نامرئی تکیه دارد: متغیرهایی که سلول‌های قبلی ساخته‌اند و هیچ‌جا نوشته نشده که کدام‌اند. تا وقتی خودت پشتِ همان runtime نشسته‌ای، این حالت هست. لحظه‌ای که کد از آن runtime بیرون می‌رود، نیست.

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

۲. اولین مرز: داده از حافظه بیرون می‌آید#

TICKETS یک متغیر در حافظهٔ نوت‌بوک است. اولین کاری که می‌کنیم بیرون آوردنش است — روی دیسک، در قالبی که هر برنامه‌ای بتواند بخواند:

import json

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

print("خطوطِ فایل:", sum(1 for _ in open("tickets.jsonl", encoding="utf-8")))
خطوطِ فایل: 769

قالبِ jsonl یعنی «هر خط یک شیءِ JSON». انتخابش تصادفی نیست: می‌شود خط‌به‌خط خواندش بدونِ اینکه کلِ فایل در حافظه بیاید، و اضافه کردن یک ردیفِ تازه یعنی یک خط به آخرِ فایل. ترمِ ۲ قالب‌های ذخیره را با عدد مقایسه می‌کند؛ فعلاً همین‌قدر بدان که این قالب برای دادهٔ خطیِ نه‌چندان بزرگ انتخابِ بی‌دردسری است.

۳. سه فایل، سه مسئولیت#

حالا کد را تقسیم می‌کنیم. قاعده‌ای که تقسیم را هدایت می‌کند این است: هر فایل به یک سؤال جواب بدهد.

  • text.py — متن را چطور تمیز می‌کنیم؟
  • data.py — داده از کجا می‌آید و کدام ردیف قابلِ‌استفاده است؟
  • model.py — مدل چطور ساخته و سنجیده می‌شود؟
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.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)
''', encoding="utf-8")

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

چهار تصمیمِ ریز در این سه فایل هست و هر چهارتا عمدی‌اند:

۱) __init__.py خالی است و باید باشد. حضورش به پایتون می‌گوید «این پوشه یک بسته است». بدونِ آن هم در نسخه‌های تازهٔ پایتون معمولاً کار می‌کند، ولی رفتارِ import در حالت‌های مرزی عوض می‌شود — پس بگذارش و فکر نکن.

۲) مسیرِ داده از __file__ حساب می‌شود، نه از پوشهٔ جاری. اگر می‌نوشتیم open("tickets.jsonl")، کد فقط وقتی کار می‌کرد که مفسر دقیقاً از همان پوشه اجرا شده باشد. این یکی از رایج‌ترین دلیل‌های «پیشِ من کار می‌کرد» است.

۳) load_tickets یک پارامترِ path دارد با مقدارِ پیش‌فرض. یعنی در استفادهٔ روزمره ساده است و در تست می‌شود فایلِ دیگری به آن داد. فصلِ بعد دقیقاً از همین دَر وارد می‌شود.

۴) train_and_score هم مدل را برمی‌گرداند و هم عدد را. تابعی که فقط چاپ می‌کند، برای کسِ دیگری بی‌فایده است — چاپ کارِ لایهٔ اجراست، نه کارِ کتابخانه.

۴. حالا import کن — و همان عدد را بگیر#

from tickets import data, model

rows = data.usable(data.load_tickets())
fitted, score = model.train_and_score(rows)
print("ردیفِ قابلِ‌استفاده:", len(rows))
print("دقت:", round(score, 4))
ردیفِ قابلِ‌استفاده: 769
دقت: 0.9016

همان ۰٫۹۰۱۶. این عددِ یکسان تصادفی نیست و مهم‌ترین چیزی است که تا اینجا ساختیم: کد جابه‌جا شد، رفتار عوض نشد.

ولی پایتون این فایل‌ها را از کجا پیدا کرد؟ از sys.path — فهرستی از پوشه‌هایی که هنگامِ import در آن‌ها می‌گردد. سلولِ راه‌اندازیِ این ترم پوشهٔ کار را به ابتدای همین فهرست اضافه کرده است. این تنها ذره‌جادوی این فصل است و بهتر است بدانی کجاست: بدونِ آن، همان import با ModuleNotFoundError می‌شکند.

🔧 اگر کار نکرد: اگر دیدی ModuleNotFoundError: No module named 'tickets'، تقریباً همیشه یکی از این دوتاست: یا سلولِ راه‌اندازی را نزده‌ای (پس مفسر داخلِ پوشهٔ کار نیست)، یا پوشهٔ tickets را جای دیگری ساخته‌ای. با print(Path.cwd()) و print(sys.path[0]) هر دو را در دو ثانیه می‌بینی.

۵. تلهٔ import: پایتون فایل را دو بار نمی‌خواند#

این تله در Colab هر روز آدم می‌گیرد: فایل را عوض می‌کنی، سلولِ import را دوباره می‌زنی، و هیچ اتفاقی نمی‌افتد.

import importlib

from tickets import text

path = Path("tickets/text.py")
path.write_text(path.read_text(encoding="utf-8") + "\nMARK = 'v2'\n", encoding="utf-8")

from tickets import text
print("بعد از import دوباره:", hasattr(text, "MARK"))

importlib.reload(text)
print("بعد از reload       :", hasattr(text, "MARK"))
بعد از import دوباره: False
بعد از reload       : True

import دومی اصلاً به دیسک نگاه نکرد. پایتون هر ماژولی را که یک بار خوانده در sys.modules نگه می‌دارد و دفعهٔ بعد همان شیء را برمی‌گرداند. این برای سرعت است و در یک برنامهٔ معمولی درست است — ولی در نوت‌بوک، که ساعت‌ها زنده می‌ماند و تو مدام فایل را عوض می‌کنی، یعنی داری کدِ قدیمی را تست می‌کنی و خبر نداری.

⚠️ مواظب باش: importlib.reload هم کاملِ کامل نیست: اگر جای دیگری from tickets.text import normalise نوشته باشی، آن نامِ محلی همچنان به تابعِ قدیمی اشاره می‌کند. مطمئن‌ترین راه در Colab یکی است: Runtime → Restart session and run all. همان آزمونی که در تمامِ این دوره می‌گوییم تنها آزمونِ صداقتِ یک نوت‌بوک است.

۶. مرزِ «کد» و «اجرا»#

آخرین قطعه: فایلی که هم بشود importش کرد و هم بشود اجرایش کرد، بدونِ اینکه این دو کارِ هم را خراب کنند.

Path("report.py").write_text(r'''from tickets import data, model


def main():
    rows = data.usable(data.load_tickets())
    _, score = model.train_and_score(rows)
    print(f"ردیف: {len(rows)}  دقت: {score:.4f}")


print("این خط همیشه اجرا می‌شود")

if __name__ == "__main__":
    main()
''', encoding="utf-8")

import report                       # فقط بارش می‌کنیم، اجرایش نمی‌کنیم
print("---")
done = subprocess.run([sys.executable, "report.py"],
                      capture_output=True, text=True, encoding="utf-8")
print(done.stdout.strip())
این خط همیشه اجرا می‌شود
---
این خط همیشه اجرا می‌شود
ردیف: 769  دقت: 0.9016

سه خطِ خروجی، سه چیزِ متفاوت می‌گویند:

  • خطِ اول موقعِ import چاپ شد. هر چیزی که در سطحِ بالای فایل بنویسی، همان لحظه که کسی فایلت را import می‌کند اجرا می‌شود. اگر آنجا آموزشِ مدل گذاشته باشی، هر کسی که فقط یک تابعِ کوچکِ تو را می‌خواهد، مجبور می‌شود مدل را آموزش بدهد.
  • خطِ دوم و سوم از مفسرِ تازه آمدند. آنجا __name__ برابرِ "__main__" بود، پس main() صدا زده شد.
  • و آن 0.9016 عیناً همان عددِ نوت‌بوک است — این بار در فرآیندی که هیچ‌کدام از متغیرهای ما را ندیده بود.

📏 اندازه بگیر: با چه چیزی مقایسه شد؟ با عددِ همان تحلیل در نوت‌بوک (0.9016). روی کدام داده؟ همان ۷۶۹ تیکت، از فایلِ روی دیسک. با چند seed؟ یکی — و اینجا کافی است، چون ادعای ما «بهتر شد» نیست، ادعای ما «مو به مو همان شد» است. برای ادعای بهبود، از فصلِ ۳ به بعد چند seed اجباری می‌شود. این تفاوت را نگه دار: بازتولید یک seed می‌خواهد، مقایسه چند تا.

چک کن: عددِ مفسرِ تازه باید دقیقاً با عددِ سلولِ اول یکی باشد. اگر یکی نشد، یعنی جایی از کدِ ماژولی با کدِ نوت‌بوکی فرق دارد — احتمالاً clean و normalise یکی نیستند، یا random_state را در یکی جا انداخته‌ای. این «تقریباً همان» نیست؛ یا دقیقاً همان است یا اشتباهی هست که هنوز پیدایش نکرده‌ای.

۷. چه ساختیم و چه هنوز نداریم#

for p in sorted(Path(".").rglob("*.py")):
    lines = len(p.read_text(encoding="utf-8").splitlines())
    print(f"{p.as_posix():<22} {lines:>3} خط")
report.py               13 خط
tickets/__init__.py      0 خط
tickets/data.py         14 خط
tickets/model.py        20 خط
tickets/text.py          9 خط
wrong_order.py           3 خط

جمعِ ستونِ راست ۵۹ خط است، ولی wrong_order.py بخشِ تحویل نیست — آن را در بخشِ ۱ ساختیم تا خرابی را نشان بدهیم و همان‌جا کارش تمام شد. چیزی که تحویل می‌شود ۵۶ خط است، و حالا قابلِ‌تحویل هم هست: کسِ دیگری می‌تواند پوشه را بردارد، python report.py بزند، و همان عدد را بگیرد.

ولی صادق باشیم — این هنوز خیلی چیزها ندارد:

  • هیچ تستی ندارد. اگر فردا normalise را عوض کنم و خرابش کنم، هیچ‌چیز به من نمی‌گوید. فصلِ ۲.
  • هیچ‌کس نگفته ورودیِ این توابع چه شکلی باید باشد. load_tickets روی فایلی که ستونِ label ندارد چه می‌کند؟ فصلِ ۴.
  • هیچ نسخه‌ای سنجاق نشده. فصلِ ۹.
  • و آن 0.9016 هنوز اثبات نشده. فصلِ ۳.

🤖 از دستیارت بپرس: «تفاوتِ import tickets.model با from tickets import model با from tickets.model import build چیست؟» بعد خودت امتحانش کن: هر سه را در یک نوت‌بوکِ تازه بزن و بعد از هرکدام print(sys.modules.keys()) را نگاه کن. کدام‌شان ماژول را واقعاً بار می‌کند و کدام‌شان فقط یک نام می‌سازد؟

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

کلمه تلفظ به حروف فارسی یعنی چه
module ماژول یک فایلِ .py که می‌شود importش کرد
package پکیج پوشه‌ای از ماژول‌ها با __init__.py
sys.path سیس‌پث فهرستِ پوشه‌هایی که پایتون هنگامِ import در آن‌ها می‌گردد
sys.modules سیس‌ماژولز حافظهٔ پنهانِ ماژول‌های بارشده در همین فرآیند
entry point اینتری پوینت جایی که اجرا از آن شروع می‌شود (اینجا main())
jsonl جیسون‌ال فایلی که هر خطش یک شیءِ JSON کامل است

تمرین‌ها

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

در فصل بعد#

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

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

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

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