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

فصل ۱ از ۸

پیشرفت ترم
۰٪

ترم ۵ · از نوت‌بوک تا چیزی که دیگران استفاده می‌کنند

تکرارپذیری: نتیجه‌ای که فردا هم همان است

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

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

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

یک مهرِ فلزی و سه اثرِ کاملاً یکسان که با آن روی کاغذ زده شده

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

  • بگویی کجای کدت تصادفی است و چطور مهارش کنی
  • اثرِ تقسیمِ تصادفی را اندازه بگیری و درست گزارش کنی
  • مدلِ آموزش‌دیده را ذخیره کنی و مطمئن باشی همان است
  • یک اثرِ انگشت برای دادهٔ خودت بسازی

قبل از شروع#

از ترمِ ۳: train_test_split، Pipeline، MAE، و اینکه random_state جایی در کد بود.

سلولِ راه‌اندازی: همان laptops.csv و laptops_clean.csv ترم‌های ۲ و ۳.

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

۱. سه چیزی که تکرار را می‌شکنند#

منبعِ ناپایداری نشانه‌اش راهِ بستنش
تصادف هر اجرا عددِ کمی متفاوت random_state / seed
نسخهٔ کتابخانه چند ماه بعد عددِ متفاوت یا خطا requirements.txt
داده همان کد، عددِ کاملاً متفاوت اثرِ انگشتِ داده

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

۲. مهارِ تصادف#

import pandas as pd
from sklearn.model_selection import train_test_split

df = pd.read_csv("laptops_clean.csv")
X = df[["year", "ram_gb", "brand", "city"]]
y = df["price"]

for seed in [0, 0, 7]:
    _, X_test, _, y_test = train_test_split(X, y, test_size=0.2, random_state=seed)
    print(f"seed={seed} -> اولین ایندکس آزمون: {X_test.index[0]} | میانگین قیمت آزمون: {round(y_test.mean())}")
seed=0 -> اولین ایندکس آزمون: 178 | میانگین قیمت آزمون: 24685417
seed=0 -> اولین ایندکس آزمون: 178 | میانگین قیمت آزمون: 24685417
seed=7 -> اولین ایندکس آزمون: 133 | میانگین قیمت آزمون: 25297917

دو اجرای اول با seed=0 دقیقاً یکی‌اند، و سومی با عددِ دیگر کاملاً متفاوت. این یعنی «تصادفی» در کامپیوتر واقعاً تصادفی نیست — یک دنبالهٔ کاملاً معین است که با یک عددِ شروع (seed) تعیین می‌شود.

⚠️ مواظب باش: random_state را در هر جایی که تصادف هست بگذار، نه فقط در train_test_split. RandomForest، KFold(shuffle=True)، GridSearchCV با جست‌وجوی تصادفی، و هر نمونه‌گیری‌ای که خودت می‌نویسی. یک random_state جامانده کافی است تا کلِ نتیجه غیرقابلِ تکرار شود — و چون تفاوت کوچک است، ماه‌ها کسی متوجهش نمی‌شود.

۳. اثرِ تصادف چقدر است؟#

مهار کردنِ تصادف کافی نیست؛ باید بدانی چقدر اثر دارد. اگر با یک seed عددِ خوبی گرفتی، آیا آن عدد واقعی است یا شانس؟

import numpy as np
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.impute import SimpleImputer
from sklearn.linear_model import Ridge
from sklearn.metrics import mean_absolute_error

prepare = ColumnTransformer([
    ("num", Pipeline([("fill", SimpleImputer(strategy="median")), ("scale", StandardScaler())]),
     ["year", "ram_gb"]),
    ("cat", OneHotEncoder(handle_unknown="ignore"), ["brand", "city"]),
])

results = {}
for seed in [0, 1, 2, 3, 4]:
    X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=seed)
    model = Pipeline([("prepare", prepare), ("model", Ridge(alpha=0.1))]).fit(X_train, y_train)
    results[seed] = mean_absolute_error(y_test, model.predict(X_test))
    print(f"random_state={seed}: MAE = {round(results[seed])}")

spread = np.array(list(results.values()))
print("کمترین:", round(spread.min()), "| بیشترین:", round(spread.max()), "| اختلاف:", round(spread.max() - spread.min()))
random_state=0: MAE = 1381886
random_state=1: MAE = 1521409
random_state=2: MAE = 1417382
random_state=3: MAE = 1390108
random_state=4: MAE = 1100034
کمترین: 1100034 | بیشترین: 1521409 | اختلاف: 421376

همان مدل، همان داده، همان کد — و ۴۲۱ هزار اختلاف، یعنی حدودِ سی درصدِ خودِ عدد. تنها چیزی که فرق کرد، این بود که کدام ۴۸ ردیف در آزمون افتادند.

حالا فکر کن اگر فقط random_state=4 را اجرا کرده بودی و «۱٫۱۰ میلیون» را گزارش کرده بودی. عدد دروغ نبود — ولی بهترینِ پنج حالت بود و کسی که رویش حساب کند، سرخورده می‌شود.

📏 اندازه بگیر: قاعدهٔ گزارش‌نویسیِ این فصل: هیچ‌وقت یک عدد از یک seed گزارش نکن. چند seed را اجرا کن و بازه را بگو: «MAE بینِ ۱٫۱۰ و ۱٫۵۲ میلیون، میانه حدودِ ۱٫۴». این همان کاری است که cross_val_score با std می‌کرد (ترمِ ۳ فصلِ ۸) — فقط اینجا یک لایه بالاتر، روی خودِ تقسیمِ آموزش و آزمون. و اگر تفاوتِ دو مدل از این بازه کوچک‌تر است، آن دو مدل تفاوتی ندارند.

۴. سنجاق کردنِ نسخه#

import sklearn
import pandas

print("pandas :", pandas.__version__)
print("sklearn:", sklearn.__version__)
print("numpy  :", np.__version__)
pandas : 3.0.3
sklearn: 1.7.2
numpy  : 2.4.6

عددهای بالا مالِ ماشینِ ماست، نه مالِ تو — و همین دقیقاً صورتِ مسئله است. Colab هم کتابخانه‌هایش را بدونِ اطلاعِ تو به‌روز می‌کند.

راهِ حل یک فایلِ ساده است: requirements.txt. هر خط یک بسته و نسخه‌اش، و هر کسی با pip install -r requirements.txt همان محیط را می‌سازد:

pandas==3.0.3
scikit-learn==1.7.2
numpy==2.4.6
joblib==1.5.2

فهرستِ کاملِ محیطِ فعلی را pip freeze می‌دهد. ولی خروجی‌اش را مستقیم در requirements.txt نریز — صدها بستهٔ نامربوط هم تویش هست. فقط چیزی را بنویس که واقعاً import می‌کنی. فایلِ کوتاه‌تر، هم خواناتر است و هم کمتر با محیطِ بعدی تعارض پیدا می‌کند.

🔧 اگر کار نکرد: پیامِ ResolutionImpossible یا conflicting dependencies یعنی دو بسته نسخه‌های ناسازگاری از یک وابستگیِ مشترک می‌خواهند. اول سخت‌گیریِ خودت را کم کن: نسخهٔ دقیقِ بسته‌های کم‌اهمیت را بردار و فقط آن‌هایی را قفل نگه دار که نتیجه‌ات واقعاً به آن‌ها حساس است — معمولاً numpy و scikit-learn.

۵. اثرِ انگشتِ داده#

ایده ساده است: از محتوای جدول یک رشتهٔ کوتاه بساز که با کوچک‌ترین تغییرِ داده کاملاً عوض شود. به آن hash می‌گویند.

کدِ زیر سه ابزار را زنجیر می‌کند و لازم نیست جزئیاتشان را بلد باشی — فعلاً همین‌قدر بدان که این سه خط با هم «انگشت‌نگاریِ محتوای جدول» را انجام می‌دهند:

  • pd.util.hash_pandas_object(frame) هر ردیف را به یک عدد تبدیل می‌کند.
  • .values.tobytes() آن عددها را به بایتِ خام تبدیل می‌کند، چون hashlib فقط بایت می‌خورد.
  • hashlib.sha256(...) از آن بایت‌ها یک اثرِ انگشتِ استاندارد می‌سازد و .hexdigest() آن را به متن درمی‌آورد. [:16] هم فقط شانزده نویسهٔ اولش را نگه می‌دارد تا خوانا بماند.

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

import hashlib

def data_fingerprint(frame):
    """یک اثر انگشت کوتاه از محتوای جدول — نه از فایل، تا به سیستم‌عامل وابسته نباشد."""
    digest = hashlib.sha256(pd.util.hash_pandas_object(frame, index=True).values.tobytes())
    return digest.hexdigest()[:16]

changed = df.copy()
changed.loc[0, "price"] = changed.loc[0, "price"] + 1

print("اثر انگشت      :", data_fingerprint(df))
print("همان داده دوباره:", data_fingerprint(pd.read_csv("laptops_clean.csv")))
print("بعد از یک تغییر :", data_fingerprint(changed))
اثر انگشت      : 833b0ebd03779d4f
همان داده دوباره: 833b0ebd03779d4f
بعد از یک تغییر : 6485e73d5f49ff11

خواندنِ دوبارهٔ همان فایل همان اثرِ انگشت را می‌دهد، و تغییرِ یک عدد در یک ردیف از ۲۳۸ ردیف، اثرِ انگشت را کاملاً عوض می‌کند.

این ۱۶ نویسه را کنارِ هر نتیجه‌ای که گزارش می‌کنی بنویس. آن‌وقت اگر شش ماه بعد عدد نخواند، در سی ثانیه می‌فهمی داده عوض شده یا کد — و آن سی ثانیه، جای روزها حدس‌زدن را می‌گیرد.

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

۶. ذخیرهٔ مدل#

مدلی که آموزش دادی، ده دقیقه یا ده ساعت وقت گرفته. آن را در RAM رها نکن؛ در نوت‌بوکِ Colab با هر قطعِ اتصال از بین می‌رود.

import joblib

X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=0)
model = Pipeline([("prepare", prepare), ("model", Ridge(alpha=0.1))]).fit(X_train, y_train)

joblib.dump(model, "price_model.joblib")
loaded = joblib.load("price_model.joblib")

print("پیش‌بینی‌ها دقیقاً یکی:", bool(np.array_equal(model.predict(X_test), loaded.predict(X_test))))

new_laptop = pd.DataFrame([{"year": 2022, "ram_gb": 16, "brand": "ایسوس", "city": "تهران"}])
print("قیمت پیشنهادی:", round(float(loaded.predict(new_laptop)[0])))
پیش‌بینی‌ها دقیقاً یکی: True
قیمت پیشنهادی: 30840171

کلِ Pipeline ذخیره شد، نه فقط Ridge — و این تفاوت همه‌چیز است. فایلِ ذخیره‌شده SimpleImputerِ آموزش‌دیده، StandardScalerِ آموزش‌دیده و OneHotEncoderِ آموزش‌دیده را هم در خودش دارد. اگر فقط مدل را ذخیره می‌کردی، هر کسی که بارش می‌کرد باید پیش‌پردازش را از نو و دقیقاً همان‌طور می‌ساخت — و اولین اختلافِ کوچک، پیش‌بینیِ غلطِ بی‌سروصدا می‌داد.

کنارِ فایلِ مدل، این‌ها را هم نگه دار چرا
requirements.txt فایلِ joblib بینِ نسخه‌های ناسازگار باز نمی‌شود
اثرِ انگشتِ داده تا بدانی روی چه چیزی آموزش دیده
نامِ ستون‌های ورودی و ترتیبشان تا بدانی چه شکل ورودی‌ای می‌خواهد
عددِ ارزیابی و seedاش تا بدانی چقدر می‌شود بهش اعتماد کرد

⚠️ مواظب باش: فایلِ joblib کدِ اجرایی در خودش دارد. یک فایلِ joblib از منبعِ ناشناس را باز نکن — دقیقاً مثلِ باز کردنِ یک برنامهٔ ناشناس است. این محدودیتِ خودِ قالب است، نه یک باگ، و برای مدل‌هایی که از اینترنت می‌گیری جدی است.

🤖 از دستیارت بپرس: «چه چیزهای دیگری در یک پروژهٔ یادگیری ماشین ممکن است نتیجه را غیرقابلِ تکرار کند؟» و بعد جوابش را با تجربهٔ خودت بسنج. (چند موردِ واقعی که ممکن است نگوید: ترتیبِ سطرهای ورودی وقتی الگوریتم به آن حساس است، تعدادِ هستهٔ پردازنده در محاسباتِ موازی، و تفاوتِ گردکردنِ اعشار بین GPU و CPU.)

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

کلمه تلفظ به حروف فارسی یعنی چه
seed سید عددِ شروعِ دنبالهٔ شبه‌تصادفی
reproducibility ریپروداسیبیلیتی اینکه اجرای دوباره همان نتیجه را بدهد
requirements.txt ریکوایرمنتس فهرستِ بسته‌ها و نسخه‌هایشان
joblib جاب‌لیب ابزارِ ذخیره و بارگذاریِ شیءهای scikit-learn
hash / fingerprint هش / فینگرپرینت رشتهٔ کوتاهی که با هر تغییرِ محتوا عوض می‌شود

تمرین‌ها

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

در فصل بعد#

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

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

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