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

فصل ۵ از ۱۰

پیشرفت ترم
۰٪

ترم ۲ · داده: دیدن، تمیز کردن، فهمیدن

دادهٔ کثیف: کارِ واقعی از اینجا شروع می‌شود

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

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

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

یک صافیِ چندطبقهٔ برش‌خورده که در هر طبقه نوعِ متفاوتی از ناخالصی جدا می‌شود

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

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

قبل از شروع#

از فصل ۳: dtypes، info()، value_counts() و پنج مشکلی که با آن‌ها پیدا کردیم.

از فصل ۴: فیلترِ شرطی و ساختنِ ستونِ تازه.

از ترمِ ۱ فصل ۳: متدهای strip() و replace() روی رشته.

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

۱. ردیفِ تکراری#

import pandas as pd

df = pd.read_csv("laptops.csv")

print("تکراری:", df.duplicated().sum())
print("قبل:", df.shape)

df = df.drop_duplicates().reset_index(drop=True)
print("بعد:", df.shape)
تکراری: 4
قبل: (244, 5)
بعد: (240, 5)

duplicated() برای هر ردیف می‌گوید آیا عیناً قبل‌تر آمده یا نه، و .sum() رویش تعدادشان را می‌دهد — همان ترفندِ ماسکِ فصلِ ۲.

آن reset_index(drop=True) مهم است: بعد از حذفِ ردیف، اندیس‌ها سوراخ‌دار می‌شوند (۰، ۱، ۲، ۴، ۵…) و این بعداً گیج‌کننده می‌شود. drop=True می‌گوید اندیسِ قدیمی را به‌عنوانِ یک ستونِ تازه نگه ندار.

⚠️ مواظب باش: «تکراری» یک تعریفِ ثابت ندارد. duplicated() به‌طورِ پیش‌فرض ردیفی را تکراری می‌داند که همهٔ ستون‌هایش یکی باشد. ولی گاهی دو آگهیِ واقعاً متفاوت اتفاقاً همهٔ فیلدهایشان یکی است، و گاهی برعکس: دو ردیف که فقط در یک فیلدِ بی‌اهمیت فرق دارند در واقع یک چیزند. duplicated(subset=["brand", "year", "price"]) می‌گذارد خودت تعریف کنی. این یک تصمیم است، نه یک دستور.

۲. ستونِ متنی که باید عدد باشد#

df["price"] = df["price"].str.replace(",", "", regex=False).astype(int)

print(df["price"].dtype)
print("میانگین:", df["price"].mean().round(0))
print("بیشینه:", df["price"].max())
int64
میانگین: 26398333.0
بیشینه: 59800000

دو قدم بود: ویرگول‌ها را بردار، بعد به عدد تبدیل کن.

آن .str قبل از replace لازم است و نکتهٔ کلیدیِ کارِ متنی در pandas است: .str دروازهٔ متدهای رشته روی یک ستون است. بدونِ آن، pandas فکر می‌کند می‌خواهی کلِ مقدارها را عوض کنی نه محتوای متنشان.

و آن regex=False هم یک آرگومانِ کوچک با اثرِ بزرگ است. برخلافِ replace رشته‌ایِ ترمِ ۱، نسخهٔ pandas می‌تواند به‌جای متنِ ساده دنبالِ یک الگو بگردد؛ و در آن حالت بعضی نویسه‌ها — از جمله . و * و ? — معنای ویژه پیدا می‌کنند و دیگر خودشان نیستند. regex=False می‌گوید «دنبالِ عینِ همین متن بگرد». برای پاک‌سازیِ ساده همیشه بنویسش؛ برای ویرگول فرقی نمی‌کند، ولی روزی که بخواهی نقطه را برداری، فرقش تفاوتِ بینِ درست و فاجعه است.

حالا میانگین و بیشینه معنا دارند. بیشینه ۵۹٫۸ میلیون است — نه آن ۹٫۱ میلیونی که در فصلِ ۳ گرفتیم.

چک کن: dtype باید int64 شده باشد. اگر هنوز متن است، یعنی astype(int) را جا انداخته‌ای. اگر ValueError گرفتی، یعنی هنوز نویسه‌ای غیر از رقم در ستون مانده — با df["price"].str.contains("[^0-9]") می‌توانی پیدایش کنی.

📏 اندازه بگیر: همین حالا میانگینِ درست (۲۶٫۴ میلیون) را با آن max غلطِ فصلِ ۳ (۹٫۱ میلیون) مقایسه کن. بیشینه از میانگین کمتر بود — یک ناممکنِ ریاضی که هیچ خطایی تولید نکرد. اگر کسی آن عدد را در یک گزارش می‌دید، هیچ نشانه‌ای نداشت که غلط است. این بهترین دلیلی است که برای «اول داده را ببین» می‌شود آورد.

۳. یکدست‌کردنِ متنِ فارسی#

print("قبل — برند:", df["brand"].nunique(), "| شهر:", df["city"].nunique())

df["brand"] = df["brand"].str.replace("ي", "ی").str.replace("ك", "ک").str.strip()
df["city"] = df["city"].str.strip()

print("بعد — برند:", df["brand"].nunique(), "| شهر:", df["city"].nunique())
print(df["brand"].value_counts())
قبل — برند: 7 | شهر: 10
بعد — برند: 5 | شهر: 5
brand
ایسوس    77
لنوو     50
دل       45
اچ پی    40
اپل      28
Name: count, dtype: int64

هفت برند شد پنج، و ده شهر شد پنج. آن ده شهر غافلگیرکننده است: پنج شهرِ واقعی، هر کدام یک بار با فاصلهٔ اضافی و یک بار بدونش. برای pandas "تهران" و " تهران " دو مقدارِ کاملاً جدا هستند، و هیچ‌کدام از دستورهای بازرسیِ فصلِ ۳ این را نشان نمی‌دادند مگر اینکه nunique را می‌دیدی.

این دو خط دو نوع مشکلِ متنیِ فارسی را حل کردند:

  • حرفِ عربی به‌جای فارسی. «ي» و «ك» عربی در ظاهر با «ی» و «ک» فارسی یکسان‌اند و در یونیکد نیستند. این در دادهٔ فارسی بسیار رایج است، چون به صفحه‌کلید و سیستم‌عاملِ کسی که تایپ کرده بستگی دارد.
  • فاصلهٔ اضافی. از کپی‌کردن، از فرم‌های وب، از هر جایی.

💡 نکته: این پاک‌سازیِ کاملِ فارسی نیست — فقط شایع‌ترین‌هاست. نیم‌فاصله، اعراب، ارقامِ فارسی و عربی، و «ه» چسبان هم هرکدام یک تصمیم می‌خواهند. فصلِ ۹ همین ترم یک ابزارِ حرفه‌ای معرفی می‌کند که همهٔ این‌ها را یکجا انجام می‌دهد.

۴. خانهٔ خالی: اول بشمار، بعد تصمیم بگیر#

missing_count = df["ram_gb"].isna().sum()
missing_ratio = df["ram_gb"].isna().mean().round(3)

print("تعداد خالی:", missing_count)
print("نسبت خالی:", missing_ratio)
print("میانه:", df["ram_gb"].median())
print("میانگین:", df["ram_gb"].mean().round(2))
تعداد خالی: 26
نسبت خالی: 0.108
میانه: 8.0
میانگین: 12.0

ده‌ونیم درصدِ ستون خالی است. حالا سه گزینه داری، و هیچ‌کدام بی‌طرف نیستند:

گزینه کد چه ادعایی می‌کنی
حذفِ ردیف df.dropna(subset=["ram_gb"]) «این ردیف‌ها برای تحلیلِ من بی‌ارزش‌اند»
پُرکردن با میانه df["ram_gb"].fillna(df["ram_gb"].median()) «این‌ها احتمالاً معمولی بوده‌اند»
نگه‌داشتنِ خالی + ستونِ نشانه df["ram_missing"] = df["ram_gb"].isna() «نبودنِ این مقدار خودش یک اطلاعات است»

توجه کن که میانه ۸ است و میانگین ۱۲. اگر با میانگین پُر کنی، ۲۶ لپ‌تاپِ ساختگی با حافظهٔ ۱۲ گیگ می‌سازی — مقداری که در دنیای واقعی اصلاً وجود ندارد (حافظه‌ها ۴ و ۸ و ۱۶ و ۳۲‌اند). این دقیقاً همان چیزی است که «تصمیم دربارهٔ معنا» یعنی.

بیایید گزینهٔ سوم را انتخاب کنیم و دلیلش را بنویسیم:

df["ram_missing"] = df["ram_gb"].isna()
df["ram_filled"] = df["ram_gb"].fillna(df["ram_gb"].median())

print(df["ram_missing"].sum())
print(df["ram_filled"].isna().sum())
print(df.loc[df["ram_missing"], ["brand", "ram_gb", "ram_filled"]].head(3))
26
0
    brand  ram_gb  ram_filled
23  اچ پی     NaN         8.0
31   لنوو     NaN         8.0
45   لنوو     NaN         8.0

ستونِ اصلی را دست‌نخورده نگه داشتیم و یک نسخهٔ پُرشده کنارش ساختیم، به‌علاوهٔ یک ستون که می‌گوید کدام‌ها ساختگی‌اند. حالا در هر تحلیلی می‌توانی هر دو را امتحان کنی و ببینی نتیجه عوض می‌شود یا نه.

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

۵. مقدارِ غیرممکن#

impossible = df[(df["year"] < 2010) | (df["year"] > 2025)]

print(impossible.shape)
print(impossible[["brand", "year"]])
(2, 7)
   brand  year
7   لنوو  2205
91  لنوو  1998

دو ردیف. 2205 تقریباً قطعاً غلطِ تایپیِ 2025 است، و 1998 هم برای این دیتاست بی‌معناست.

(اگر از (2, 7) جا خوردی: جدول پنج ستون داشت، ولی در بخشِ ۴ دو ستونِ ram_missing و ram_filled را خودمان اضافه کردیم. عددِ دومِ shape همیشه ستون‌های همین لحظه را می‌شمارد، نه ستون‌های فایلِ اصلی را.)

حالا سؤالِ واقعی: حذفشان کنی یا درستشان کنی؟

  • حذف ساده و امن است. دو ردیف از ۲۴۰، تأثیرش ناچیز است.
  • تصحیح (2205 → 2025) اطلاعات را نگه می‌دارد ولی یک حدس است. اگر واقعاً 2005 بوده چه؟

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

before = len(df)
df = df[(df["year"] >= 2010) & (df["year"] <= 2025)].reset_index(drop=True)

print(f"{before - len(df)} ردیف حذف شد، {len(df)} ماند")
print(df["year"].describe().round(2))
2 ردیف حذف شد، 238 ماند
count     238.00
mean     2019.97
std         2.56
min      2016.00
25%      2018.00
50%      2020.00
75%      2022.00
max      2024.00
Name: year, dtype: float64

انحرافِ معیارِ year از ۱۲٫۲ به ۲٫۵۶ رسید. یعنی آن عددِ قبلی تقریباً کاملاً حاصلِ همان دو ردیفِ خراب بود، نه تنوعِ واقعیِ داده.

چک کن: حتماً پیامِ «چند ردیف حذف شد» را چاپ کن. حذفِ خاموش بدترین عادتِ پاک‌سازی است: شش ماه بعد کسی — شاید خودت — نگاه می‌کند و نمی‌داند چرا تعداد ردیف‌ها با فایلِ اصلی نمی‌خواند.

۶. یک خطای واقعی: .str جامانده#

df["city"].strip()
AttributeError: 'Series' object has no attribute 'strip'

پیام دقیقاً درست است: strip متدِ رشته است، نه متدِ Series. یک ستون یک رشته نیست، مجموعه‌ای از رشته‌هاست.

.str همان پلی است که متدهای رشته را روی هر عنصرِ ستون اجرا می‌کند: df["city"].str.strip().

🔧 اگر کار نکرد: هر وقت AttributeError: 'Series' object has no attribute ... گرفتی و آن متد را از دنیای رشته می‌شناسی (lower, split, replace, contains, startswith)، جوابت تقریباً همیشه اضافه‌کردنِ .str است.

🤖 از دستیارت بپرس: «برای دادهٔ فارسی، چه نوع ناهماهنگی‌های متنی‌ای وجود دارد که با چشم دیده نمی‌شوند؟ فقط فهرست کن.» جوابش احتمالاً «ی»/«ك» عربی، نیم‌فاصله، اعراب، ارقامِ فارسی/عربی و فاصلهٔ مجازی را می‌آورد. برای هر کدام، یک نمونهٔ کوچک بساز و با repr() چاپش کن تا با چشمِ خودت تفاوتِ نامرئی را ببینی.

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

کلمه تلفظ به حروف فارسی یعنی چه
duplicated دوپلیکِیتد آیا این ردیف عیناً قبل‌تر آمده
NaN نَن نشانهٔ «مقداری وجود ندارد» در ستونِ عددی
isna / fillna / dropna ایز-اِن‌اِی / فیل-اِن‌اِی / دراپ-اِن‌اِی خالی است؟ / پُرش کن / ردیفش را بینداز
.str دات اِس‌تی‌آر دروازهٔ متدهای رشته روی یک ستون
nunique اِن‌یونیک تعدادِ مقدارهای متمایزِ یک ستون

تمرین‌ها

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

در فصل بعد#

جدولت حالا تمیز است: ۲۳۸ ردیفِ قابل‌اعتماد با ستون‌های درست‌نوع. فصلِ بعد اولین سؤال‌های واقعی را از آن می‌پرسد: کدام برند گران‌تر است؟ در کدام شهر آگهیِ بیشتری هست؟ قیمت با سال چطور تغییر می‌کند؟ ابزارش groupby است — و همان‌جا می‌بینی که یک جدولِ ۲۳۸ ردیفی می‌تواند در سه خط به یک جدولِ پنج‌ردیفیِ کاملاً گویا تبدیل شود.

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

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