تحلیل احساسات (Sentiment Analysis) مسئلهی پایهای و محک کلاسیک در پردازش زبان طبیعی است. در نگاه اول برچسبزدن به یک جملهی مثبت یا منفی کار سادهای به نظر میرسد، اما چالشهایی مثل افعال نفی («چندان جالب نبود»)، لحن کنایهآمیز و تفاوت بار معنایی کلمات در حوزههای مختلف، به سرعت مدلهای سادهلوحانه را به زانو درمیآورند.
در این درس یاد میگیرید که چطور پایپلاینهای استاندارد دستهبندی متن را بسازید. ابتدا مدل نایو بیز (Naive Bayes) و رگرسیون لجستیک (Logistic Regression) را از صفر پیادهسازی میکنیم، تکنیک مدیریت قلمرو نفی (Negation Scoping) را برای جلوگیری از خطاهای رایج به کار میبریم و سپس همین خط لوله را به شکلی تمیز و بهینهسازیشده در scikit-learn پیاده میکنیم.
برای اجرای کدهای این آموزش نیازی به کارتهای گرافیک گرانقیمت یا سرویسهای ابری خارجی ندارید؛ مدلهای خطی و بازنماییهای TF-IDF با کمترین بار پردازشی بهراحتی روی CPU سیستم شخصی شما اجرا میشوند و سرعت توسعهی بالایی را برای ساخت خط مبنا (Baseline) در اختیارتان میگذارند.
مسئلهی استاندارد و بنیادین در NLP؛ بیشتر نکاتی که باید دربارهی دستهبندی کلاسیک متن بدانید، همینجا خودش را نشان میدهد.
صورت مسئله
«غذا چندان جالب نبود.» مثبت است یا منفی؟
تحلیل احساسات (sentiment analysis) در ظاهر ساده به نظر میرسد: کاربری نظر داده و گفته از چیزی خوشش آمده یا نه؛ کافی است به جمله برچسب بزنیم. اما دلیل تبدیل شدنش به مسئلهی معیار در NLP این است که پشت هر نمونهی بهظاهر ساده، چالشی دشوار پنهان شده است. کلمات نفی جهت معنا را برمیگردانند. طعنه و کنایه (sarcasm) آن را کاملاً معکوس میکند. عبارت «not bad at all» با وجود داشتن دو واژهی منفی، در کل مثبت است. ایموجیها گاهی از متن اطرافشان سیگنال بسیار بیشتری دارند. واژگان تخصصی هر حوزه نیز تعیینکنندهاند؛ مثلاً واژهی tight در نقد موسیقی بار معنایی متفاوتی با همان واژه در نقد مد و پوشاک دارد.
تحلیل احساسات، آزمایشگاه عملی روشهای کلاسیک NLP است. اگر بفهمید چرا هر مدل پایهی ساده در سناریوی خاصی شکست میخورد، دلیل ابداع تکتک مدلهای غنیتر بعدی را متوجه خواهید شد. در این درس، یک مدل پایهی نایو بیز (Naive Bayes) را از صفر پیادهسازی میکنیم، رگرسیون لجستیک (Logistic Regression) را به آن اضافه میکنیم و دامهایی را برمیشماریم که استقرار مدل احساسات در محیط عملیاتی را به مسئلهای در سطح انطباق با قوانین (compliance-grade) تبدیل میکنند.
مفاهیم پایه
تحلیل احساسات کلاسیک یک دستورالعمل دومرحلهای است:
- بازنمایی (Represent): تبدیل متن به بردار ویژگی؛ با روشهایی مثل BoW، TF-IDF یا n-gramها.
- دستهبندی (Classify): برازش یک مدل خطی (نایو بیز، رگرسیون لجستیک، SVM) روی نمونههای برچسبدار.
نایو بیز سادهترین مدلی است که واقعاً جواب میدهد: فرض کنید به شرط مشخص بودن برچسب، تکتک ویژگیها از یکدیگر مستقلاند. مقادیر P(word | positive) و P(word | negative) را بر اساس تعداد تکرارها تخمین بزنید. در زمان استنتاج، احتمالات را در هم ضرب کنید. این فرض «سادهلوحانه» یا نایو دربارهی استقلال ویژگیها خندهدار و غلط است، اما نتایج آن بهطرز شگفتآوری قدرت دارند. دلیلش این است که در ویژگیهای خلوت (sparse) متنی با حجم دادهی متوسط، برای دستهبند مهمتر است که بداند هر کلمه به کدام سمت متمایل است، نه اینکه چقدر تمایل دارد.
رگرسیون لجستیک فرض استقلال ویژگیها را برطرف میکند. این مدل برای هر ویژگی یک وزن یاد میگیرد، از جمله وزنهای منفی. بنابراین، ترکیب دوتایی (bigram) مثل not good یک وزن منفی میگیرد؛ کاری که نایو بیز نمیتواند برای بیگِرامهایی که هرگز برچسب نخوردهاند انجام دهد.
پیادهسازی از صفر
گام 1: یک مجموعهدادهی کوچکِ واقعی
POSITIVE = [
"absolutely loved this movie",
"beautiful cinematography and a great story",
"one of the best films of the year",
"brilliant acting from the lead",
"heartwarming and funny",
]
NEGATIVE = [
"boring and far too long",
"not worth your time",
"the plot made no sense",
"terrible acting, awful script",
"i want my two hours back",
]
اندازهی مجموعه را عمداً کوچک گرفتهایم. در پروژههای واقعی با دهها هزار نمونه کار میکنید (مثل IMDb، SST-2 و Yelp polarity)، ولی محاسبات ریاضی پشت آنها دقیقاً یکسان است.
گام 2: پیادهسازی نایو بیز چندجملهای از صفر
import math
from collections import Counter
def train_nb(docs_by_class, vocab, alpha=1.0):
class_priors = {}
class_word_probs = {}
total_docs = sum(len(d) for d in docs_by_class.values())
for cls, docs in docs_by_class.items():
class_priors[cls] = len(docs) / total_docs
counts = Counter()
for doc in docs:
for token in doc:
counts[token] += 1
total = sum(counts.values()) + alpha * len(vocab)
class_word_probs[cls] = {
w: (counts[w] + alpha) / total for w in vocab
}
return class_priors, class_word_probs
def predict_nb(doc, class_priors, class_word_probs):
scores = {}
for cls in class_priors:
s = math.log(class_priors[cls])
for token in doc:
if token in class_word_probs[cls]:
s += math.log(class_word_probs[cls][token])
scores[cls] = s
return max(scores, key=scores.get)
هموارسازی افزایشی (alpha=1.0) همان هموارسازی لاپلاس (Laplace smoothing) است. بدون این اصلاح، اگر کلمهای در یک کلاس دیده نشده باشد، احتمالش صفر میشود و لگاریتم منفجر خواهد شد (به خطای منفی بینهایت میرسد). در کارهای عملی مقدار alpha=0.01 رایج است، اما alpha=1.0 پیشفرض آموزشی به حساب میآید.
گام 3: پیادهسازی رگرسیون لجستیک از صفر
import numpy as np
def sigmoid(x):
return 1.0 / (1.0 + np.exp(-np.clip(x, -20, 20)))
def train_lr(X, y, epochs=500, lr=0.05, l2=0.01):
n_features = X.shape[1]
w = np.zeros(n_features)
b = 0.0
for _ in range(epochs):
logits = X @ w + b
preds = sigmoid(logits)
err = preds - y
grad_w = X.T @ err / len(y) + l2 * w
grad_b = err.mean()
w -= lr * grad_w
b -= lr * grad_b
return w, b
def predict_lr(X, w, b):
return (sigmoid(X @ w + b) >= 0.5).astype(int)
منظمسازی L2 (L2 regularization) در اینجا اهمیت زیادی دارد. ویژگیهای متنی خلوت هستند؛ بدون L2 مدل نمونههای آموزشی را حفظ میکند. کار را با مقدار 0.01 شروع کنید و آن را تنظیم (tune) نمایید.
گام 4: مدیریت کلمات نفی (پاشنهی آشیل مدل)
دو عبارت «not good» و «not bad» را در نظر بگیرید. دستهبند مبتنی بر BoW فقط مجموعههای {not, good} و {not, bad} را میبیند و بر اساس هر کلمهای که در آموزش بیشتر آمده باشد یاد میگیرد. اما دستهبند مبتنی بر بیگِرام، not_good و not_bad را میبیند و آنها را بهعنوان ویژگیهایی متمایز یاد میگیرد. این کار معمولاً کافی است.
یک راهکار سادهتر برای وقتهایی که به بیگِرامها دسترسی ندارید، تعیین قلمرو نفی (negation scoping) است: به اول توکنهای پس از کلمهی منفیکننده تا رسیدن به اولین نشانهی نگارشی، پیشوند NOT_ بزنید.
NEGATION_WORDS = {"not", "no", "never", "nor", "none", "nothing", "neither"}
NEGATION_TERMINATORS = {".", "!", "?", ",", ";"}
def apply_negation(tokens):
out = []
negate = False
for token in tokens:
if token in NEGATION_TERMINATORS:
negate = False
out.append(token)
continue
if token in NEGATION_WORDS:
negate = True
out.append(token)
continue
out.append(f"NOT_{token}" if negate else token)
return out
>>> apply_negation(["not", "good", "at", "all", ".", "but", "funny"])
['not', 'NOT_good', 'NOT_at', 'NOT_all', '.', 'but', 'funny']
حالا good و NOT_good دو ویژگی متفاوت محسوب میشوند. دستهبند میتواند به هر کدام وزن متضادی بدهد. با سه خط پیشپردازش، شاهد جهش قابلاندازهگیری در دقت روی بنچمارکهای تحلیل احساسات خواهید بود.
گام 5: معیارهای ارزیابی که واقعاً اهمیت دارند
اگر دادهها نامتوازن باشند، صحت (accuracy) بهتنهایی گمراهکننده است. در پیکرههای متنی واقعی، معمولاً 70 تا 80 درصد دادهها مثبت یا 70 تا 80 درصد منفی هستند؛ بنابراین دستهبندی که همیشه کلاس اکثریت را پیشبینی کند به صحت 80 درصدی میرسد، ولی عملاً بیارزش است. حتماً تمام موارد زیر را گزارش کنید:
- دقت (Precision) و بازخوانی (Recall) به تفکیک کلاسها: برای هر کلاس یک جفت از این دو معیار. سپس از آنها میانگین کلان (macro-average) بگیرید تا به یک عدد واحد برسید که توازن کلاسها را لحاظ میکند.
- امتیاز Macro-F1 (معیار اصلی برای دادههای نامتوازن): میانگین نمرات F1 هر کلاس، با وزن یکسان. وقتی کلاسها نامتوازناند، از این معیار بهجای صحت استفاده کنید.
- امتیاز Weighted-F1 (معیار جایگزین): مانند macro است ولی با فراوانی هر کلاس وزندهی میشود. وقتی عدمتوازن کلاسها بار معنایی و ارزش تجاری دارد، آن را در کنار macro-F1 گزارش کنید.
- ماتریس درهمریختگی (Confusion Matrix): شمارشهای خام. پیش از اعتماد به هر معیار عددی تکمقداری (اسکالر)، این ماتریس را بررسی کنید؛ چون نشان میدهد مدل کدام جفت از کلاسها را با هم اشتباه میگیرد.
- بررسی خطاهای نمونه به تفکیک کلاسها: برای هر کلاس 5 پیشبینی اشتباه را جدا کرده و بخوانید. هیچ تحلیلی جای خواندن خطاهای واقعی را نمیگیرد.
برای دادههایی با عدمتوازن شدید (نسبت بیش از 95 به 5)، بهجای صحت، معیارهای AUROC و AUPRC را گزارش کنید. معیار AUPRC نسبت به کلاس اقلیت حساستر است؛ یعنی دقیقاً همان کلاسی که معمولاً برای شما اهمیت دارد (تشخیص هرزنامه، تقلب، یا احساسات نادر).
باگ رایج: گزارش micro-F1 بهجای macro-F1 روی دادههای نامتوازن، عددی بالا به دست میدهد که ناشی از تسلط کلاس اکثریت است. در مقابل، macro-F1 مجبورتان میکند عملکرد مدل روی کلاس اقلیت را ببینید.
def evaluate(y_true, y_pred):
tp = sum(1 for t, p in zip(y_true, y_pred) if t == 1 and p == 1)
fp = sum(1 for t, p in zip(y_true, y_pred) if t == 0 and p == 1)
fn = sum(1 for t, p in zip(y_true, y_pred) if t == 1 and p == 0)
tn = sum(1 for t, p in zip(y_true, y_pred) if t == 0 and p == 0)
precision = tp / (tp + fp) if tp + fp else 0
recall = tp / (tp + fn) if tp + fn else 0
f1 = 2 * precision * recall / (precision + recall) if precision + recall else 0
return {"tp": tp, "fp": fp, "tn": tn, "fn": fn, "precision": precision, "recall": recall, "f1": f1}
استفاده از ابزارهای واقعی
کتابخانهی scikit-learn این کار را در شش خط کد و کاملاً درست پیاده میکند:
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import Pipeline
pipe = Pipeline([
("tfidf", TfidfVectorizer(ngram_range=(1, 2), min_df=2, sublinear_tf=True, stop_words=None)),
("clf", LogisticRegression(C=1.0, max_iter=1000)),
])
pipe.fit(X_train, y_train)
print(pipe.score(X_test, y_test))
به سه نکته توجه کنید: stop_words=None کلمات نفی را نگه میدارد. گزینهی ngram_range=(1, 2) بیگِرامها را اضافه میکند تا not good به یک ویژگی تبدیل شود (scikit-learn این دو کلمه را با یک فاصله به هم میچسباند). پارامتر sublinear_tf=True اثر تکرار کلمات را تعدیل میکند. تنظیم همین سه گزینه، تفاوت میان صحت 75 درصدی و 85 درصدی روی SST-2 است.
چه زمانی باید سراغ ترنسفورمر رفت؟
- تشخیص طعنه و کنایه (sarcasm). مدلهای کلاسیک اینجا شکست میخورند؛ بدون استثنا.
- نقدهای طولانی که لحن و بار احساسی در میانهی متن عوض میشود.
- تحلیل احساسات مبتنی بر جنبه (Aspect-based sentiment): مانند «دوربین فوقالعاده بود اما باتری افتضاح عمل میکرد». باید احساسات را به جنبههای خاصی نسبت دهید؛ این کار فقط از عهدهی مدلهای ترنسفورمر یا مدلهای با خروجی ساختاریافته برمیآید.
- زبانهای غیرانگلیسی با منابع محدود. مدل چندزبانهی BERT یک مدل پایهی صفر-شات (zero-shot) آماده در اختیارتان میگذارد.
اگر به مواردی غیر از این نیاز دارید، مستقیم به فاز 7 (بررسی عمیق ترنسفورمرها) بروید. در غیر این صورت، ترکیب نایو بیز یا رگرسیون لجستیک روی TF-IDF همراه با بیگِرامها و مدیریت کلمات نفی، مدل پایهی عملیاتی شما برای سال 2026 خواهد بود.
تلهی بازتولیدپذیری (یک بار دیگر)
آموزش دوبارهی مدلهای احساسات امری روتین است، اما ارزیابی مجدد آنها خیر. اعداد صحتی که در مقالهها میبینید از افرازهای (splits) خاص، پیشپردازشهای معین و توکنایزرهای ویژهای به دست آمدهاند. اگر مدل جدیدتان را بدون استفاده از همان پایپلاین (Pipeline) دقیق با مدل پایه مقایسه کنید، تغییرات و اختلافاتی (deltas) که میبینید گمراهکننده خواهند بود. همیشه مدل پایه را روی پایپلاین خودتان بازتولید کنید، نه بر اساس عددی که در مقاله ثبت شده است.
تحویل پروژه
خروجی را در مسیر outputs/prompt-sentiment-baseline.md ذخیره کنید:
---
name: sentiment-baseline
description: Design a sentiment analysis baseline for a new dataset.
phase: 5
lesson: 05
---
Given a dataset description (domain, language, size, label granularity, latency budget), you output:
1. Feature extraction recipe. Specify tokenizer, n-gram range, stopword policy (usually keep), negation handling (scoped prefix or bigrams).
2. Classifier. Naive Bayes for baseline, logistic regression for production, transformer only if the domain needs sarcasm / aspects / cross-lingual.
3. Evaluation plan. Report precision, recall, F1, confusion matrix, and per-class error samples (not just scalars).
4. One failure mode to monitor post-deployment. Domain drift and sarcasm are the top two.
Refuse to recommend dropping stopwords for sentiment tasks. Refuse to report accuracy as the sole metric when classes are imbalanced (e.g., 90% positive). Flag subword-rich languages as needing FastText or transformer embeddings over word-level TF-IDF.
تمرینها
- ساده. تابع
apply_negationرا بهصورت یک گام پیشپردازش به پایپلاین scikit-learn اضافه کنید و میزان تغییر F1 را روی یک مجموعهدادهی کوچک احساسات بسنجید. - متوسط. رگرسیون لجستیک با وزندهی به کلاسها را پیادهسازی کنید (از طریق آرگومان
class_weight="balanced"در scikit-learn، یا با مشتقگیری دستی گرادیان). تأثیر آن را روی یک عدمتوازن کلاس ساختگی با نسبت 90 به 10 اندازه بگیرید. - سخت. با آموزش یک دستهبند دوم روی ماندهها (residuals) و خطاهای مدل احساسات، یک آشکارساز طعنه و کنایه بسازید. چارچوب آزمایشی خود را مستند کنید. اگر دقت مدلتان از حد شانس کمتر شد، به خواننده هشدار دهید (حد شانس در دستهبندی دوکلاسهی طعنه حدود 50 درصد است و بیشتر تلاشهای اول به همین عدد ختم میشوند).
اصطلاحات کلیدی
| اصطلاح | چیزی که معمولاً گفته میشود | معنای دقیق فنی |
|---|---|---|
| جهتگیری (Polarity) | مثبت یا منفی | برچسب دوگانه؛ گاهی به خنثی یا درجهبندیهای ریزدانهتر (سیستم 5 ستاره) تعمیم مییابد. |
| تحلیل مبتنی بر جنبه (Aspect-based sentiment) | جهتگیری بر اساس هر جنبه | نسبت دادن احساسات به موجودیتها یا ویژگیهای مشخصی که در متن آمدهاند. |
| تعیین قلمرو نفی (Negation scoping) | برگرداندن معنای توکنهای مجاور | اضافه کردن پیشوند NOT_ به توکنهای پس از واژهی منفیکننده تا رسیدن به اولین نشانهی نگارشی. |
| هموارسازی لاپلاس (Laplace smoothing) | اضافه کردن 1 به شمارشها | جلوگیری از تولید ویژگیهای با احتمال صفر در نایو بیز. |
| منظمسازی L2 (L2 regularization) | جمعوجور کردن و کاهش وزنها | افزودن عبارت lambda * sum(w^2) به تابع زیان (loss)؛ امری حیاتی برای ویژگیهای متنی خلوت. |
برای مطالعهی بیشتر
- Pang and Lee (2008). Opinion Mining and Sentiment Analysis — مقالهی مرجع و بنیادی این حوزه. هرچند طولانی است، اما چهار بخش اول آن تمام مباحث کلاسیک را پوشش میدهد.
- Wang and Manning (2012). Baselines and Bigrams: Simple, Good Sentiment and Topic Classification — مقالهای که نشان داد شکست دادن ترکیب بیگِرام + نایو بیز در متنهای کوتاه بسیار سخت است.
- scikit-learn text feature extraction docs — مستندات استخراج ویژگیهای متنی برای
CountVectorizer،TfidfVectorizerو تمامی پیچوتنظیمهایی که دستکاری خواهید کرد.
