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

آخر این فصل میتوانی:
- بگویی کجای کدت تصادفی است و چطور مهارش کنی
- اثرِ تقسیمِ تصادفی را اندازه بگیری و درست گزارش کنی
- مدلِ آموزشدیده را ذخیره کنی و مطمئن باشی همان است
- یک اثرِ انگشت برای دادهٔ خودت بسازی
قبل از شروع#
از ترمِ ۳: 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 تمیزتر میشود.
به آخر این فصل رسیدی!
اگر ساختی و جواب داد، این دکمه مال توست.