مدلهای یادگیری ماشین توان خواندن متن خام را ندارند و ورودی آنها اعداد ریاضی است. پردازش زبان طبیعی (NLP) پلی میان متنهای پیوسته و ساختارهای گسسته است؛ به همین دلیل اگر مراحل نخست پیشپردازش شامل توکنسازی، ریشهیابی و لماتایز کردن درست طراحی نشوند، دادههای نهایی دچار واگرایی شدید خواهند شد.
در این درس ساختار پایپلاینهای آمادهسازی متن را بررسی میکنیم و ضمن پیادهسازی الگوریتمها از پایه، کاربرد و تفاوتهای کتابخانههای NLTK و spaCy را مرور خواهیم کرد. همچنین به چالشهای پنهان پروداکشن نظیر ناسازگاری میان نسخهها و اختلاف ورودی آموزش با استنتاج پرداخته میشود.
اگر برای راهاندازی و اجرای ابزارها بستههای پایتون مانند NLTK یا spaCy و مدلهای زبانی آنها را دانلود میکنید، توجه داشته باشید که دریافت بستهها از مخازن خارجی مانند PyPI گاهی از داخل ایران با کندی یا قطعی مواجه میشود و استفاده از آینههای داخلی یا ابزارهای مدیریت وابستگی مناسب راهکار پایداری برای این موارد است.
زبان پیوسته است. مدلها گسستهاند. پیشپردازش پل میان این دو است.
صورت مسئله
مدل نمیتواند جملهٔ «.The cats were running» را بخواند؛ مدل فقط اعداد صحیح را میفهمد.
هر سیستم پردازش زبان طبیعی (NLP) با سه پرسش یکسان آغاز میشود: مرز کلمه کجاست؟ ریشهٔ کلمه چیست؟ و چطور کلمات «run»، «running» و «ran» را وقتی به کار میآید یکی بدانیم و وقتی فایدهای ندارد، متفاوت در نظر بگیریم؟
اگر توکنسازی (Tokenization) را اشتباه انجام دهید، مدل از دادههای بیارزش یاد میگیرد. اگر توکنایزر شما don't را یک توکن اما do n't را دو توکن بشناسد، توزیع دادههای آموزشی دوپاره میشود. اگر ریشهیاب (Stemmer) شما دو کلمهٔ organization و organ را به یک ریشه تبدیل کند، مدلسازی موضوعی (Topic modeling) نابود میشود. اگر لماتایزر (Lemmatizer) شما به بافت نقش دستوری کلمه نیاز داشته باشد اما آن را ارسال نکنید، فعلها بهجای اسم در نظر گرفته میشوند.
در این درس، این سه مرحلهٔ پیشپردازش را از صفر میسازیم؛ سپس پیادهسازی آنها را در NLTK و spaCy بررسی میکنیم تا تفاوتها و مصالحههای میانشان را بهعینه ببینید.
مفهوم
سه عملیات؛ هر کدام با یک وظیفه و یک نقطهٔ شکست.
توکنسازی (Tokenization) رشتهٔ متنی را به توکنها تقسیم میکند. واژهٔ «توکن» عمداً کلی تعریف شده است؛ زیرا میزان ریزدانگیِ مناسب به نوع کار بستگی دارد: سطح کلمه برای NLP کلاسیک، زیرکلمه (Subword) برای ترنسفورمرها، و کاراکتر برای زبانهای بدون فاصله.
ریشهیابی صرفی (Stemming) پسوندها را بر اساس قواعد مشخصی حذف میکند؛ سریع، تهاجمی و ناآگاهانه: running -> run. و organization -> organ؛ که مورد دوم دقیقاً نقطهٔ شکست آن است.
ریشهیابی لغوی (Lemmatization) کلمه را با استفاده از دانش گرامری به شکل واژهنامهایِ آن برمیگرداند؛ کندتر و دقیقتر است و به جدول جستجو یا تحلیلگر ساختواژی (Morphological analyzer) نیاز دارد: ran -> run (باید بداند «ran» زمان گذشتهٔ «run» است). و better -> good (باید صورتهای تفضیلی را بشناسد).
یک قاعدهٔ سرانگشتی: وقتی سرعت مهم است و میتوانید نویز را تحمل کنید، سراغ Stemming بروید (نمایهسازی جستجو، دستهبندی کلی). وقتی معنا اهمیت دارد، از Lemmatization استفاده کنید (پاسخدهی به سؤالات، جستجوی معنایی، و هر خروجیای که کاربر نهایی قرار است بخواند).
پیادهسازی از صفر
گام 1: یک توکنایزر کلمات مبتنی بر Regex
سادهترین توکنایزر کاربردی، متن را بر اساس کاراکترهای غیرالفبایی-عددی میشکند و در عین حال علائم نگارشی را هم بهشکل توکنهای مستقل حفظ میکند. بینقص نیست، نسخهٔ نهایی هم نیست، اما در یک خط اجرا میشود.
import re
def tokenize(text):
return re.findall(r"[A-Za-z]+(?:'[A-Za-z]+)?|[0-9]+|[^\sA-Za-z0-9]", text)
سه الگو بر اساس اولویت: کلماتی که احتمالاً آپاستروف میانی دارند (don't، it's)؛ اعداد خالص؛ و هر کاراکتر تکیِ غیرفاصله و غیرالفبایی-عددی بهشکل یک توکن مستقل (علائم نگارشی).
>>> tokenize("The cats weren't running at 3pm.")
['The', 'cats', "weren't", 'running', 'at', '3', 'pm', '.']
نقاط شکستی که باید به آنها دقت کنید: عبارت 3pm به ['3', 'pm'] شکسته میشود، چون الگو بین دنبالهای از حروف و دنبالهای از ارقام جابهجا شده است. این رفتار برای بیشتر کارها کافی است. آدرسهای وب، ایمیلها و هشتگها همگی به مشکل میخورند. برای محیط پروداکشن، باید الگوهای مربوط به آنها را پیش از الگوهای عمومی اضافه کنید.
گام 2: ریشهیاب پورتر (فقط گام 1a)
الگوریتم کامل پورتر (Porter) پنج فازِ قاعدهمحور دارد. گام 1a بهتنهایی پرتکرارترین پسوندهای انگلیسی را پوشش میدهد و منطق کلی این الگو را به شما میآموزد.
def stem_step_1a(word):
if word.endswith("sses"):
return word[:-2]
if word.endswith("ies"):
return word[:-2]
if word.endswith("ss"):
return word
if word.endswith("s") and len(word) > 1:
return word[:-1]
return word
>>> [stem_step_1a(w) for w in ["caresses", "ponies", "caress", "cats"]]
['caress', 'poni', 'caress', 'cat']
قواعد را از بالا به پایین بخوانید. قاعدهٔ ies -> i دلیل این است که ponies به poni تبدیل میشود، نه به pony. در نسخهٔ کامل پورتر، گام 1b این مشکل را برطرف میکند. قواعد با هم رقابت میکنند و اولویت با قاعدهای است که زودتر آمده باشد. ترتیب قواعد حتی از خود تکتکِ قواعد مهمتر است.
گام 3: یک لماتایزر مبتنی بر جدول جستجو
ریشهیابی لغویِ اصولی به ساختواژه (Morphology) نیاز دارد. نسخهٔ آموزشی و جمعوجورِ آن میتواند از یک جدول کوچک و یک راهکار جایگزین (Fallback) استفاده کند.
LEMMA_TABLE = {
("running", "VERB"): "run",
("ran", "VERB"): "run",
("runs", "VERB"): "run",
("better", "ADJ"): "good",
("best", "ADJ"): "good",
("cats", "NOUN"): "cat",
("cat", "NOUN"): "cat",
("were", "VERB"): "be",
("was", "VERB"): "be",
("is", "VERB"): "be",
}
def lemmatize(word, pos):
key = (word.lower(), pos)
if key in LEMMA_TABLE:
return LEMMA_TABLE[key]
if pos == "VERB" and word.endswith("ing"):
return word[:-3]
if pos == "NOUN" and word.endswith("s"):
return word[:-1]
return word.lower()
>>> lemmatize("running", "VERB")
'run'
>>> lemmatize("cats", "NOUN")
'cat'
>>> lemmatize("better", "ADJ")
'good'
>>> lemmatize("watched", "VERB")
'watched'
مورد آخر نکتهٔ آموزشی کلیدی است: watched در جدول ما نیست و راهکار جایگزین ما فقط ing را پوشش میدهد. لماتایزر واقعی مواردی مثل ed، فعلهای بیقاعده، صفتهای تفضیلی، و جمعهای بیقاعده با تغییر واکه (children -> child) را مدیریت میکند. به همین دلیل سیستمهای پروداکشن از WordNet، ماژول Morphologizer در spaCy یا یک تحلیلگر ساختواژی کامل استفاده میکنند.
گام 4: اتصال اجزا در قالب یک پایپلاین
def preprocess(text, pos_tagger=None):
tokens = tokenize(text)
stems = [stem_step_1a(t.lower()) for t in tokens]
tags = pos_tagger(tokens) if pos_tagger else [(t, "NOUN") for t in tokens]
lemmas = [lemmatize(word, pos) for word, pos in tags]
return {"tokens": tokens, "stems": stems, "lemmas": lemmas}
تکهٔ مفقود در این زنجیره، برچسبزن نقش دستوری (POS tagger) است. در درس 5 · 07 (POS Tagging) یکی از آنها را میسازیم. فعلاً بهطور پیشفرض همهچیز را NOUN در نظر میگیریم و این محدودیت را میپذیریم.
استفاده از ابزارهای واقعی
کتابخانههای NLTK و spaCy نسخههای آماده برای پروداکشن را عرضه کردهاند؛ هر کدام تنها با چند خط کد.
NLTK
import nltk
nltk.download("punkt_tab")
nltk.download("wordnet")
nltk.download("averaged_perceptron_tagger_eng")
from nltk.tokenize import word_tokenize
from nltk.stem import PorterStemmer, WordNetLemmatizer
from nltk import pos_tag
text = "The cats were running."
tokens = word_tokenize(text)
stems = [PorterStemmer().stem(t) for t in tokens]
lemmatizer = WordNetLemmatizer()
tagged = pos_tag(tokens)
def nltk_pos_to_wordnet(tag):
if tag.startswith("V"):
return "v"
if tag.startswith("J"):
return "a"
if tag.startswith("R"):
return "r"
return "n"
lemmas = [lemmatizer.lemmatize(t, nltk_pos_to_wordnet(tag)) for t, tag in tagged]
تابع word_tokenize مخففها، یونیکد و حالتهای خاصی را که رجکس سادهٔ شما جا میاندازد مدیریت میکند. کلاس PorterStemmer تمام پنج فاز را اجرا میکند. کلاس WordNetLemmatizer نیاز دارد که برچسب نقش دستوری از ساختار Penn Treebank در NLTK به مجموعه اختصارات WordNet تبدیل شود. کد نگاشت بالا همان بخشی است که بیشتر آموزشها از رویش میپرند.
spaCy
import spacy
nlp = spacy.load("en_core_web_sm")
doc = nlp("The cats were running.")
for token in doc:
print(token.text, token.lemma_, token.pos_)
The the DET
cats cat NOUN
were be AUX
running run VERB
. . PUNCT
کتابخانهٔ spaCy کل پایپلاین را پشت دستور nlp(text) پنهان میکند: توکنسازی، برچسبگذاری نقش دستوری و لماتایز کردن همگی همزمان اجرا میشوند. در مقیاسهای بزرگ، سریعتر از NLTK است و خروجی پیشفرض دقیقتری دارد. مصالحه در اینجاست که نمیتوانید اجزای داخلی آن را بهسادگی تعویض کنید.
چه زمانی سراغ کدام برویم؟
| موقعیت | انتخاب مناسب |
|---|---|
| آموزش، کارهای پژوهشی، نیاز به تعویض اجزای سیستم | NLTK |
| پروداکشن، چندزبانه، اهمیت بالای سرعت | spaCy |
| پایپلاین ترنسفورمر (در هر صورت از توکنایزر اختصاصی مدل استفاده میکنید) | استفاده از tokenizers یا transformers و کنار گذاشتن پیشپردازش سنتی |
دو نقطهٔ شکستی که هیچکس دربارهشان هشدار نمیدهد
بیشتر آموزشها الگوریتمها را توضیح میدهند و کار را تمام میکنند. اما دو مشکل عمده وجود دارد که پایپلاینهای پیشپردازش واقعی را به دردسر میاندازد و تقریباً هیچ جا پوشش داده نمیشوند:
انحراف در بازتولیدپذیری (Reproducibility drift): رفتار NLTK و spaCy در توکنسازی و لماتایز کردن از نسخهای به نسخهٔ دیگر تغییر میکند. کلماتی که در spaCy 2.x بهصورت ['do', "n't"] توکن میشدند، ممکن است در نسخهٔ 3.x خروجی ["don't"] بدهند. مدل شما روی یک توزیع داده آموزش دیده، اما حالا استنتاج (Inference) روی توزیع متفاوتی اجرا میشود. دقت مدل بیسروصدا افت میکند و هیچکس هم علتش را نمیفهمد. نسخهٔ کتابخانهها را در requirements.txt پین (قفل) کنید. یک تست رگرسیون برای پیشپردازش بنویسید که توکنسازیِ مورد انتظار برای 20 جملهٔ نمونه را تثبیت کند و با هر ارتقای نسخه، آن را اجرا کنید.
ناهمخوانی میان آموزش و استنتاج (Training / inference mismatch): مدل را با پیشپردازش تهاجمی (حروف کوچک، حذف کلمات توقف، ریشهیابی به روش Stemming) آموزش دهید، آن را روی ورودی خام کاربر مستقر کنید و سقوط کاراییاش را تماشا کنید! این رایجترین دلیل شکست پروژههای NLP در پروداکشن است. اگر در مرحلهٔ آموزش پیشپردازشی انجام میدهید، باید دقیقاً همان تابع را در زمان استنتاج هم اجرا کنید. توابع پیشپردازش را در قالب تابعی داخل پکیج نهایی مدل عرضه کنید، نه بهشکل یک سلول ژوپیتر نوتبوک که تیم استقرار مجبور شود دوباره آن را بازنویسی کند.
خروجی این درس
یک پرامپت با قابلیت استفادهٔ مجدد که به مهندسان کمک میکند بدون نیاز به خواندن چندین کتاب مرجع، استراتژی پیشپردازش خود را انتخاب کنند.
این متن را در فایل outputs/prompt-preprocessing-advisor.md ذخیره کنید:
---
name: preprocessing-advisor
description: Recommends a tokenization, stemming, and lemmatization setup for an NLP task.
phase: 5
lesson: 01
---
You advise on classical NLP preprocessing. Given a task description, you output:
1. Tokenization choice (regex, NLTK word_tokenize, spaCy, or transformer tokenizer). Explain why.
2. Whether to stem, lemmatize, both, or neither. Explain why.
3. Specific library calls. Name the functions. Quote the POS-tag translation if NLTK is involved.
4. One failure mode the user should test for.
Refuse to recommend stemming for user-visible text. Refuse to recommend lemmatization without POS tags. Flag non-English input as needing a different pipeline.
تمرینها
- ساده. تابع
tokenizeرا طوری توسعه دهید که نشانیهای اینترنتی (URL) را بهشکل یک توکن واحد حفظ کند. آزمون: عبارتtokenize("Visit https://example.com today.")باید یک توکن واحد برای URL تولید کند. - متوسط. گام 1b از الگوریتم پورتر را پیادهسازی کنید: اگر کلمهای شامل حروف صدادار بود و به
edیاingختم میشد، این پسوندها را حذف کنید. قاعدهٔ همخوان مضاعف را نیز مدیریت کنید (hopping -> hop، نهhopp). - سخت. لماتایزری بسازید که از WordNet بهعنوان جدول جستجو استفاده کند، اما اگر کلمهای در WordNet پیدا نشد، به ریشهیاب پورتر شما رجوع کند (Fallback). دقت آن را روی یک پیکرهٔ برچسبخورده در مقایسه با WordNet خالص و پورتر خالص بسنجید.
اصطلاحات کلیدی
| اصطلاح | آنچه دیگران میگویند | معنای دقیق آن |
|---|---|---|
| توکن (Token) | یک کلمه | هر واحدی که مدل مصرف میکند؛ میتواند کلمه، زیرکلمه، کاراکتر یا بایت باشد. |
| ریشه به روش Stemming (ساقه) | ریشهٔ یک کلمه | نتیجهٔ حذف قاعدهمحور پسوندها؛ همیشه یک کلمهٔ واقعی در زبان نیست. |
| ریشه به روش Lemmatization (لِما) | شکل واژهنامهای کلمه | همان صورتی که در لغتنامه جستجو میکنید؛ برای محاسبهٔ درست به بافت گرامری نیاز دارد. |
| برچسب نقش دستوری (POS tag) | نوع کلمه در جمله | دستههایی مانند NOUN، VERB، ADJ؛ برای لماتایز کردن دقیق ضروری است. |
| ساختواژه (Morphology) | قواعد شکل ظاهری کلمه | چگونگی تغییر فرم کلمه بر اساس زمان، شمار، حالت؛ عملکرد Lemmatization کاملاً به آن وابسته است. |
برای مطالعهٔ بیشتر
- Porter, M. F. (1980). An algorithm for suffix stripping — مقالهٔ اصلی، پنج صفحه، و همچنان شفافترین توضیح در این زمینه.
- spaCy 101 — linguistic features — نحوهٔ پیادهسازی و اتصال یک پایپلاین واقعی.
- NLTK book, chapter 3 — گوشههای پنهان و حالتهای خاص توکنسازی که هنوز به آنها فکر نکردهاید.
