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

فصل ۹ از ۱۰

پیشرفت ترم
۰٪

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

دادهٔ متنیِ فارسی

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

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

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

دو نوارِ متنِ به‌ظاهر یکسان که زیرِ ذره‌بین، نویسه‌های متفاوتی نشان می‌دهند

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

  • بگویی چرا دو متنِ فارسیِ به‌ظاهر یکسان می‌توانند برای پایتون متفاوت باشند
  • با Normalizer متنِ فارسی را یکدست کنی
  • متن را به کلمه و جمله بشکنی
  • stopword را حذف کنی و پرتکرارهای واقعی را پیدا کنی
  • بگویی ریشه‌یابی چه می‌کند و کجا کم می‌آورد

قبل از شروع#

از ترمِ ۱ فصل ۳: split(), strip(), replace().

از ترمِ ۱ فصل ۱۱: پروژهٔ شمارشِ کلمه — همان کار را اینجا درست انجام می‌دهیم.

از فصل ۵: یکدست‌کردنِ «ی» و «ک» عربی با .str.replace.

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

۱. تفاوتی که نمی‌بینی#

a = "کتاب‌ها"
b = "کتاب ها"
c = "كتاب‌ها"

print(a == b, a == c)
print(len(a), len(b), len(c))
print(repr(a))
print(repr(c))
False False
7 7 7
'کتاب\u200cها'
'كتاب\u200cها'

سه متن که روی صفحه تقریباً یکسان‌اند و هیچ‌کدام با هم برابر نیستند.

repr() نویسه‌های نامرئی را آشکار می‌کند، و همین‌جا کارِ اصلی‌اش را نشان داد: آن که وسطِ کلمه ظاهر شد، نیم‌فاصله است — نویسه‌ای که فاصلهٔ بصری می‌سازد ولی کلمه را نمی‌شکند. در چاپِ معمولی اصلاً دیده نمی‌شود؛ فقط repr نشانش می‌دهد.

دومی به‌جای نیم‌فاصله، فاصلهٔ معمولی دارد — پس برای split() دو کلمه است نه یکی. و سومی با «ك» عربی شروع می‌شود، که در repr هم مثلِ «ک» فارسی به‌نظر می‌رسد و فقط == تفاوتش را لو می‌دهد.

توجه کن هر سه len برابرِ ۷ دارند. طولِ یکسان هیچ چیزی را ثابت نمی‌کند — نیم‌فاصله هم یک نویسه است، درست مثلِ فاصلهٔ معمولی.

💡 نکته: repr() را در کارِ متنیِ فارسی مثلِ ذره‌بین به‌کار ببر. هر وقت دو مقدار «باید یکی باشند ولی نیستند»، اول repr هر دو را چاپ کن. این ارزان‌ترین ابزارِ اشکال‌زداییِ متن است.

۲. hazm: نصب و یکدست‌سازی#

hazm در Colab از پیش نصب نیست:

pip install -q hazm

سپس:

from hazm import Normalizer

normalizer = Normalizer()

titles = [
    "لپ تاپ ايسوس با گارانتي، در حد نو",
    "فروش فوري لپ‌تاپ لنوو ٨ گيگ رم",
    "لپ‌تاپ اپل مک‌بوک، بدون خط و خش",
    "لپ تاپ  دل  کارکرده  با کيف",
    "لپ‌تاپ اچ پی گیمینگ، رم ۱۶ گیگ",
]

for title in titles[:2]:
    print(repr(title))
    print(repr(normalizer.normalize(title)))
'لپ تاپ ايسوس با گارانتي، در حد نو'
'لپ\u200cتاپ ایسوس با گارانتی، در حد نو'
'فروش فوري لپ\u200cتاپ لنوو ٨ گيگ رم'
'فروش فوری لپ\u200cتاپ لنوو ۸ گیگ رم'

یک خط کد، چهار تصحیحِ متفاوت:

  • «ي» و «ك» عربی ← «ی» و «ک» فارسی («گارانتي» شد «گارانتی») — همان کاری که در فصلِ ۵ با دست کردیم.
  • «لپ تاپ» ← «لپ‌تاپ» — فاصلهٔ معمولی را در ترکیب‌های شناخته‌شده به نیم‌فاصله تبدیل کرد.
  • رقمِ عربی ← رقمِ فارسی («٨» شد «۸»). این سه دستهٔ رقم — لاتین، فارسی، عربی — به چشم شبیه‌اند و برای پایتون سه چیزند.
  • فاصله‌های اضافی جمع شد.

چک کن: خروجی را با repr نگاه کن نه با print ساده. تفاوتِ نیم‌فاصله و فاصله در چاپِ معمولی اصلاً دیده نمی‌شود — و دقیقاً همین است که این باگ را ماندگار می‌کند.

⚠️ مواظب باش: Normalizer تصمیم می‌گیرد، نه اینکه فقط تمیز کند. مثلاً «لپ تاپ» را به «لپ‌تاپ» چسباند — که برای این داده درست است، ولی اگر داده‌ات نامِ خاصی داشته باشد که نباید چسبانده شود، عوضش می‌کند. همیشه چند نمونهٔ قبل و بعد را با چشم مقایسه کن، مخصوصاً روی داده‌ای که ساختارِ خاص دارد.

۳. توکن‌سازی: شکستن به کلمه و جمله#

from hazm import sent_tokenize, word_tokenize

clean_title = normalizer.normalize(titles[0])

print(word_tokenize(clean_title))
print(sent_tokenize("لپ‌تاپ سالم است. قیمت مقطوع! تماس بگیرید؟"))
['لپ\u200cتاپ', 'ایسوس', 'با', 'گارانتی', '،', 'در', 'حد', 'نو']
['لپ\u200cتاپ سالم است.', 'قیمت مقطوع!', 'تماس بگیرید؟']

word_tokenize از split() بهتر است چون نشانه‌های نگارشی را جدا می‌کند — آن «،» یک توکنِ مستقل شد، نه چسبیده به «گارانتی». در پروژهٔ ترمِ ۱ همین را با یک فهرستِ دستیِ نشانه‌ها حل کردیم؛ اینجا درست انجام می‌شود.

sent_tokenize هم متن را به جمله می‌شکند و «.» و «!» و «؟» را می‌شناسد. برای وقتی که می‌خواهی نظرِ بلندِ یک کاربر را جمله‌به‌جمله تحلیل کنی، این نقطهٔ شروع است.

۴. stopword: کلمه‌هایی که چیزی نمی‌گویند#

from hazm import stopwords_list

stops = stopwords_list()
print(stops[:8])
print("«با» جزو stopword است؟", "با" in stops)
['آخرین', 'آقای', 'آمد', 'آمده', 'آمده_است', 'آن', 'آنان', 'آنجا']
«با» جزو stopword است؟ True

stopword کلمه‌ای است که در هر متنی زیاد تکرار می‌شود و دربارهٔ موضوع چیزی نمی‌گوید: «و»، «از»، «به»، «که»، «را». اگر حذفشان نکنی، فهرستِ پرتکرارهایت پُر از همین‌ها می‌شود — دقیقاً همان چیزی که در تمرینِ ستاره‌دارِ پروژهٔ ترمِ ۱ دیدی.

حالا هر سه ابزار را با هم به‌کار ببریم. برای شمارش این بار به‌جای حلقهٔ دستی از Counter استفاده می‌کنیم — همان کاری که در پروژهٔ ترمِ ۱ با counts.get(word, 0) + 1 نوشتی، آماده در کتابخانهٔ استانداردِ پایتون:

from collections import Counter

print(Counter(["داده", "سرنخ", "داده"]))
print(Counter(["داده", "سرنخ", "داده"]).most_common(1))
Counter({'داده': 2, 'سرنخ': 1})
[('داده', 2)]

Counter یک فهرست می‌گیرد و «مقدار ← تعداد» می‌دهد، و متدِ most_common(n) آن n تای پرتکرار را به‌ترتیبِ نزولی برمی‌گرداند، به‌صورتِ فهرستی از tuple. دقیقاً همان چیزی که در ترمِ ۱ با دست ساختی — و دلیلِ اینکه آنجا با دست ساختیمش همین است که حالا بدانی این خط چه می‌کند، نه اینکه فقط اجرایش کنی.

words = []
for title in titles:
    tokens = word_tokenize(normalizer.normalize(title))
    words += [w for w in tokens if w not in stops and len(w) > 1]

print(Counter(words).most_common(6))
[('لپ\u200cتاپ', 5), ('گیگ', 2), ('رم', 2), ('ایسوس', 1), ('گارانتی', 1), ('نو', 1)]

دو نکتهٔ کوچک در همین چند خط: len(w) > 1 نشانه‌های نگارشیِ تک‌نویسه‌ای را می‌اندازد، و words += [...] یعنی «این فهرست را به آخرِ words بچسبان» — شکلِ کوتاهِ words = words + [...]، همان الگوی انباشتگرِ ترمِ ۱ فصلِ ۶.

نتیجه گویاست: «لپ‌تاپ» در هر پنج آگهی هست (پس برای تفکیکِ آگهی‌ها بی‌فایده است) و «گیگ» و «رم» دو بار.

📏 اندازه بگیر: توجه کن «لپ‌تاپ» ۵ از ۵ شد. کلمه‌ای که در همهٔ ردیف‌ها هست، درست به‌اندازهٔ کلمه‌ای که در هیچ ردیفی نیست بی‌فایده است — هیچ‌کدام به تفکیکِ ردیف‌ها کمکی نمی‌کنند. این شهود در ترمِ ۴ فصلِ ۵ به یک معیارِ عددی به‌نامِ TF-IDF تبدیل می‌شود و پایهٔ همهٔ کارهای متنیِ جدی است.

۵. ریشه‌یابی، و جایی که کم می‌آورد#

from hazm import Lemmatizer, Stemmer

stemmer = Stemmer()
lemmatizer = Lemmatizer()

for word in ["لپ‌تاپ‌ها", "کارکرده", "گیمینگ", "فروشنده"]:
    print(word, "|", stemmer.stem(word), "|", lemmatizer.lemmatize(word))
لپ‌تاپ‌ها | لپ‌تاپ | لپ‌تاپ
کارکرده | کارکرده | کارکرده
گیمینگ | گیمینگ | گیمینگ
فروشنده | فروشنده | فروشنده
  • Stemmer پسوندها را با قاعده می‌بُرد. «لپ‌تاپ‌ها» شد «لپ‌تاپ» — و همین یک کار خیلی به‌درد می‌خورد، چون جمع و مفردِ یک کلمه دیگر دو چیزِ جدا شمرده نمی‌شوند.
  • Lemmatizer هوشمندتر است و دنبالِ شکلِ فرهنگ‌نامه‌ایِ کلمه می‌گردد. برای فعل‌ها خیلی بهتر عمل می‌کند.

ولی به سه موردِ آخر نگاه کن: هیچ‌کدام تغییر نکردند. «گیمینگ» یک وام‌واژه است و در فرهنگِ ابزار نیست؛ «فروشنده» و «کارکرده» هم دست‌نخورده ماندند.

این محدودیت واقعی است و باید بشناسی‌اش. ابزارهای زبانی روی زبانِ معیار خوب کار می‌کنند و روی زبانِ بازار، محاوره و وام‌واژه ضعیف‌ترند — و دادهٔ واقعیِ فارسی پُر از همین‌هاست.

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

۶. یک خطای واقعی: Normalizer روی چیزی که متن نیست#

normalizer.normalize(1400)
AttributeError: 'int' object has no attribute 'strip'

hazm انتظارِ رشته دارد. روی دادهٔ واقعی این خیلی زود پیش می‌آید، چون یک ستونِ متنی معمولاً چند خانهٔ عددی یا خالی هم دارد.

راهِ آشکار این است که ستون را به متن تبدیل کنی و بعد normalize را روی تک‌تکِ خانه‌ها اجرا کنی. ابزارش .apply است:

💡 نکته: .apply(f) یک تابع می‌گیرد و آن را روی هر خانهٔ ستون جدا اجرا می‌کند — همان حلقه‌ای که در ترمِ ۱ با دست می‌نوشتی، در یک متد. و به normalizer.normalize بدونِ پرانتز دقت کن: با پرانتز یعنی «الان صدایش بزن»، بدونِ پرانتز یعنی «خودِ تابع را به apply بده تا او صدایش بزند». فعلاً همین‌قدر بدان؛ در ترمِ ۴ فصلِ ۸ کاملاً بازش می‌کنیم و آنجا می‌بینی چرا این ایده مهم‌تر از یک ترفندِ نوشتاری است.

ولی تبدیلِ نوع به‌تنهایی کافی نیست:

import pandas as pd

ads = pd.DataFrame({"title": titles + [1400, None]})
ads["title"].astype(str).apply(normalizer.normalize)
AttributeError: 'float' object has no attribute 'translate'

همان خطا، باز هم. چرا؟ چون astype(str) خانهٔ خالی را متن نمی‌کند؛ خالی نگهش می‌دارد. و مقدارِ خالی در pandas یک عددِ اعشاریِ خاص است — همان float که در پیامِ خطا آمده.

پس ترتیب مهم است: اول خالی‌ها، بعد تبدیلِ نوع.

import pandas as pd

ads = pd.DataFrame({"title": titles + [1400, None]})
ads["clean"] = ads["title"].fillna("").astype(str).apply(normalizer.normalize)

print(ads["clean"].tolist()[-2:])
print(ads.shape)
print("متن خالی:", (ads["clean"] == "").sum())
['۱۴۰۰', '']
(7, 2)
متن خالی: 1

حالا کار می‌کند. و به عددِ 1400 نگاه کن: به ۱۴۰۰ با رقمِ فارسی تبدیل شد. Normalizer نمی‌داند این یک عدد بوده نه یک کلمه — پاک‌سازیِ متنی را روی ستونی اجرا کن که واقعاً متن است.

🔧 اگر کار نکرد: الگوی درست همیشه سه قدم است و ترتیبش مهم: fillna("")astype(str) ← پاک‌سازی. اگر برعکس بزنی خطا می‌گیری، و اگر fillna("نامشخص") بزنی یک کلمهٔ ساختگی وارد شمارشت می‌کنی. خالی را با رشتهٔ خالی پُر کن و بعد صریح بشمارش — دقیقاً همان تفاوتِ «صفر» و «هیچ» که از ترمِ ۱ دنبالش می‌کنیم.

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

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

کلمه تلفظ به حروف فارسی یعنی چه
hazm هضم کتابخانهٔ متن‌بازِ پردازشِ زبانِ فارسی
normalization نرمالایزیشِن یکدست‌کردنِ شکل‌های مختلفِ نوشتنِ یک متن
tokenize توکنایز شکستنِ متن به کلمه یا جمله
stopword استاپ‌ورد کلمهٔ پرتکراری که دربارهٔ موضوع چیزی نمی‌گوید
Counter کانتِر شمارندهٔ آمادهٔ پایتون: فهرست می‌گیرد، «مقدار ← تعداد» می‌دهد
stem / lemma استِم / لِما ریشهٔ قاعده‌ایِ کلمه / شکلِ فرهنگ‌نامه‌ایِ آن

تمرین‌ها

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

در فصل بعد#

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

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

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