فاز 5 · درس 2

پیاده‌سازی Bag of Words و TF-IDF از صفر تا scikit-learn

نوع
ساختنی
زمان
حدود 75 دقیقه
زبان‌ها
Python
پیش‌نیاز
فاز 5 · درس 01 (پردازش متن)، فاز 2 · درس 02 (رگرسیون خطی از صفر)
ترازویی فلزی و دقیق که در یک کفه آن توده‌ای از کلمات متراکم و در کفه دیگر چند واژه سبک اما شاخص قرار دارد
فهرست مطالب
  1. صورت مسئله
  2. مفهوم
  3. پیاده‌سازی از صفر
  4. مرحله‌ی 1: ساخت فرهنگ لغت
  5. مرحله‌ی 2: کیسه‌ی کلمات
  6. مرحله‌ی 3: بسامد واژه و بسامد سندی
  7. مرحله‌ی 4: الگوریتم TF-IDF
  8. مرحله‌ی 5: نرمال‌سازی L2 روی سطرها
  9. استفاده از ابزارهای واقعی
  10. جاهایی که TF-IDF هنوز برنده است (در سال 2026)
  11. جاهایی که TF-IDF شکست می‌خورد
  12. روش ترکیبی: امبدینگ با وزن‌دهی TF-IDF
  13. خروجی نهایی
  14. تمرین‌ها
  15. اصطلاحات کلیدی
  16. برای مطالعه‌ی بیشتر

مدل‌های یادگیری ماشین توان پردازش مستقیم متن را ندارند و باید ورودی متنی را به بردارهای عددی تبدیل کنیم. گرچه امروزه مدل‌های زبانی بزرگ و امبدینگ‌های چندصد میلیون پارامتری محبوبیت زیادی پیدا کرده‌اند، اما در بسیاری از تسک‌های عملیاتی مانند تشخیص اسپم، دسته‌بندی موضوعی یا فیلتر لاگ‌ها، روش‌های کلاسیک بهینه‌تر هستند.

روش‌های کیسه‌ی کلمات (Bag of Words) و TF-IDF به دلیل سرعت استنتاج بر حسب میکروثانیه، تفسیرپذیری بالا و عدم نیاز به زیرساخت پردازشی سنگین، همچنان یکی از ابزارهای اصلی مهندسی داده محسوب می‌شوند. در این درس، ابتدا این الگوریتم‌ها را از صفر پیاده‌سازی می‌کنیم و سپس به نحوه استفاده استاندارد از آن‌ها در scikit-learn می‌پردازیم.

کدها و محاسبات این درس کاملاً متکی به پردازنده (CPU) سیستم شخصی هستند و برای اجرای آن‌ها به هیچ GPU یا اتصال به API خارجی نیاز نخواهید داشت؛ تنها نصب بسته‌های پایه پایتون از طریق pip برای اجرای آن کافی است.

اول بشمار، بعد فکر کن. TF-IDF در سال 2026 هنوز در تسک‌های مشخص و محدود، مدل‌های امبدینگ را شکست می‌دهد.

صورت مسئله

مدل به عدد نیاز دارد؛ شما فقط رشته‌های متنی (String) دارید.

همه‌ی پایپ‌لاین‌های پردازش زبان طبیعی (NLP) باید به یک پرسش پاسخ دهند: چطور جریانی از توکن‌ها با طول متغیر را به یک بردار با اندازه‌ی ثابت تبدیل کنیم تا کلاسیفایر بتواند آن را پردازش کند؟ اولین پاسخی که این حوزه به آن رسید، ساده‌ترین روشی بود که کار می‌کرد: کلمات را بشمار و بردار بساز.

این بردار، بیشتر از هر مدل امبدینگی بار سیستم‌های عملیاتیِ NLP را به دوش کشیده است: فیلترهای اسپم، دسته‌بندی موضوعی، تشخیص ناهنجاری در لاگ‌ها، رتبه‌بندی نتایج جستجو (قبل از BM25)، موج اول تحلیل احساسات و دهه‌ی اول بنچمارک‌های دانشگاهی NLP. متخصصان در سال 2026 هنوز برای تسک‌های کلاسیفیکیشنِ محدود، اول سراغ همین روش می‌روند؛ چون سریع است، تفسیرپذیری بالایی دارد و در تسک‌هایی که فقط «حضور کلمات» اهمیت دارد، خروجی‌اش اغلب با یک مدل امبدینگ 400 میلیون پارامتری فرقی نمی‌کند.

در این درس ابتدا کیسه‌ی کلمات (Bag of Words) و سپس TF-IDF را از صفر پیاده‌سازی می‌کنیم. بعد می‌بینیم که scikit-learn چطور همین کار را در سه خط انجام می‌دهد؛ و در نهایت سراغ حالت‌های نقصی می‌رویم که شما را مجبور می‌کنند سراغ امبدینگ‌ها بروید.

مفهوم

کیسه‌ی کلمات (BoW) ترتیب کلمات را دور می‌ریزد. برای هر سند، می‌شماریم که هر کلمه‌ی فرهنگ لغت چند بار تکرار شده است. طول بردار برابر با اندازه‌ی فرهنگ لغت است و جایگاه i، تعداد تکرار کلمه‌ی i را نشان می‌دهد.

الگوریتم TF-IDF به خروجی BoW وزن دوباره می‌دهد. کلمه‌ای که در همه‌ی سندها آمده باشد اطلاعات مفیدی ندارد، پس وزنش را کم می‌کنیم. در مقابل، کلمه‌ای که در کل اسناد کمیاب است ولی در یک سند خاص زیاد تکرار شده، حاوی سیگنال مهمی است؛ پس وزنش را بالا می‌بریم.

TF-IDF(w,d)=TF(w,d)⋅IDF(w)=count(w in d)∣d∣⋅log⁡Ndf(w)\begin{aligned} \text{TF-IDF}(w, d) &= \text{TF}(w, d) \cdot \text{IDF}(w) \\ &= \frac{\mathop{\mathrm{count}}(w \text{ in } d)}{|d|} \cdot \log\frac{N}{\mathop{\mathrm{df}}(w)} \end{aligned}

در اینجا TF بسامد واژه در سند، df بسامد سندی (تعداد اسنادی که این کلمه در آن‌ها آمده) و N تعداد کل اسناد است. تابع log وزن کلمات همه‌جاحاضر را محدود نگه می‌دارد.

ویژگی کلیدی: هر دو روش، بردارهای خلوت (Sparse vectors) با محورهای تفسیرپذیر می‌سازند. می‌توانید به وزن‌های یک کلاسیفایرِ آموزش‌دیده نگاه کنید و بخوانید که کدام کلمات، سند را به سمت کدام کلاس push می‌کنند. چنین کاری را با امبدینگ 768 بعدی BERT هرگز نمی‌توانید انجام دهید.

کیسه کلمات / TF-IDFیک واژه انتخاب کنید
کیسه کلمات (BoW) فراوانی خام را می‌شمارد، بنابراین کلماتی مثل «the» در همه جا مهم به نظر می‌رسند. مدل TF-IDF هر شمارش را در log(N تقسیم بر document-frequency) ضرب می‌کند: کلمه‌ای که در تمام اسناد وجود دارد، idf آن صفر شده و وزنش از بین می‌رود، در حالی که کلمه‌ای که فقط در یک سند باشد idf بالایی گرفته و برجسته می‌شود. کلمات نادر و متمایزکننده برنده می‌شوند.

پیاده‌سازی از صفر

مرحله‌ی 1: ساخت فرهنگ لغت

def build_vocab(docs):
    vocab = {}
    for doc in docs:
        for token in doc:
            if token not in vocab:
                vocab[token] = len(vocab)
    return vocab

ورودی: لیستی از اسناد توکنایزشده (هر توکنایزر کلمه‌محوری کار را راه می‌اندازد؛ فایل code/main.py در این درس از نسخه‌ی ساده‌شده‌ی تبدیل به حروف کوچک استفاده می‌کند). خروجی: دیکشنری {word: index}. حفظ ترتیب درج (Insertion order) یعنی کلمه‌ی اندیس 0، همان اولین کلمه‌ای است که در سند اول دیده شده. این قرارداد متفاوت است؛ scikit-learn کلمات را بر اساس حروف الفبا مرتب می‌کند.

مرحله‌ی 2: کیسه‌ی کلمات

def bag_of_words(docs, vocab):
    matrix = [[0] * len(vocab) for _ in docs]
    for i, doc in enumerate(docs):
        for token in doc:
            if token in vocab:
                matrix[i][vocab[token]] += 1
    return matrix
>>> docs = [["cat", "sat", "on", "mat"], ["cat", "cat", "ran"]]
>>> vocab = build_vocab(docs)
>>> bag_of_words(docs, vocab)
[[1, 1, 1, 1, 0], [2, 0, 0, 0, 1]]

سطرها نشان‌دهنده‌ی اسناد و ستون‌ها اندیس‌های فرهنگ لغت هستند. درایه‌ی [i][j] یعنی «کلمه‌ی j چند بار در سند i آمده است». سند شماره‌ی 1 کلمه‌ی cat را دو بار دارد چون در متن دو بار آمده؛ سند شماره‌ی 0 کلمه‌ی ran را صفر بار دارد چون اصلاً در آن نیامده است.

مرحله‌ی 3: بسامد واژه و بسامد سندی

import math


def term_frequency(doc_bow, doc_length):
    return [c / doc_length if doc_length else 0 for c in doc_bow]


def document_frequency(bow_matrix):
    df = [0] * len(bow_matrix[0])
    for row in bow_matrix:
        for j, count in enumerate(row):
            if count > 0:
                df[j] += 1
    return df


def inverse_document_frequency(df, n_docs):
    return [math.log((n_docs + 1) / (d + 1)) + 1 for d in df]

دو ترفند هموارسازی (Smoothing) که ارزش نام بردن دارند: عبارت (n+1)/(d+1) جلوی log(x/0) را می‌گیرد؛ اضافه‌کردن +1 در انتها هم تضمین می‌کند کلمه‌ای که در تمام سندها وجود دارد، همچنان IDF برابر با 1 (و نه 0) داشته باشد که با رفتار پیش‌فرض scikit-learn مطابقت دارد. پیاده‌سازی‌های دیگر از فرمول خام log(N/df) استفاده می‌کنند؛ هر دو کار می‌کنند، اما نسخه‌ی هموارشده رفتار سازگارتری دارد.

مرحله‌ی 4: الگوریتم TF-IDF

def tfidf(bow_matrix):
    n_docs = len(bow_matrix)
    df = document_frequency(bow_matrix)
    idf = inverse_document_frequency(df, n_docs)
    out = []
    for row in bow_matrix:
        length = sum(row)
        tf = term_frequency(row, length)
        out.append([tf_j * idf_j for tf_j, idf_j in zip(tf, idf)])
    return out
>>> docs = [
...     ["the", "cat", "sat"],
...     ["the", "dog", "sat"],
...     ["the", "cat", "ran"],
... ]
>>> vocab = build_vocab(docs)
>>> bow = bag_of_words(docs, vocab)
>>> tfidf(bow)

سه سند و پنج کلمه در فرهنگ لغت داریم (the، cat، sat، dog، ran). کلمه‌ی the در هر سه سند آمده، پس IDF پایینی دارد. کلمه‌ی dog فقط در یک سند آمده، پس IDF آن بالاست. در یک فرهنگ لغت واقعی با هزاران کلمه، این بردارها خلوت خواهند بود (بیشتر درایه‌ها صفرند). حتی در این مثال اسباب‌بازی هم کلمات تمایزبخش کاملاً به چشم می‌آیند.

مرحله‌ی 5: نرمال‌سازی L2 روی سطرها

def l2_normalize(matrix):
    out = []
    for row in matrix:
        norm = math.sqrt(sum(x * x for x in row))
        out.append([x / norm if norm else 0 for x in row])
    return out

بدون نرمال‌سازی، سند طولانی‌تر بردار بزرگ‌تری می‌گیرد و امتیاز شباهت را تسخیر می‌کند. نرمال‌سازی L2 همه‌ی اسناد را روی یک اَبَرکره‌ی واحد قرار می‌دهد. حالا شباهت کسینوسی بین سطرها صرفاً همان ضرب داخلی است.

استفاده از ابزارهای واقعی

کتابخانه‌ی scikit-learn نسخه‌ی آماده برای پروداکشن را عرضه می‌کند.

from sklearn.feature_extraction.text import CountVectorizer, TfidfVectorizer

docs = ["the cat sat on the mat", "the dog sat on the mat", "the cat ran"]

bow_vectorizer = CountVectorizer()
bow = bow_vectorizer.fit_transform(docs)
print(bow_vectorizer.get_feature_names_out())
print(bow.toarray())

tfidf_vectorizer = TfidfVectorizer()
tfidf = tfidf_vectorizer.fit_transform(docs)
print(tfidf.toarray().round(3))

کلاس CountVectorizer توکنایز، استخراج فرهنگ لغت و ساخت BoW را در یک فراخوانی انجام می‌دهد. کلاس TfidfVectorizer وزن‌دهی IDF و نرمال‌سازی L2 را اضافه می‌کند. هر دو ماتریس خلوت برمی‌گردانند. برای 100 هزار سند، نسخه‌ی متراکم (Dense) در حافظه جا نمی‌شود؛ پس تا زمانی که کلاسیفایر بردار متراکم نخواسته، روی فرمت ماتریس خلوت بمانید.

پیچ‌های تنظیمی که همه‌چیز را تغییر می‌دهند:

آرگومان اثر
ngram_range=(1, 2) بای‌گرام‌ها (ترکیب‌های دو کلمه‌ای) را اضافه می‌کند. معمولاً دقت کلاسیفیکیشن را بالا می‌برد.
min_df=2 کلماتی را که در کمتر از 2 سند آمده‌اند حذف می‌کند. برای سبک‌کردن فرهنگ لغت روی داده‌های پرنویز مفید است.
max_df=0.95 کلماتی را که در بیش از 95% اسناد آمده‌اند حذف می‌کند. معادل حذف ایست‌واژه‌ها (Stop words) بدون نیاز به لیست دستی و هاردکدشده.
stop_words="english" لیست توکار ایست‌واژه‌ها در scikit-learn. بستگی به تسک دارد؛ در تحلیل احساسات نباید کلمات منفی‌کننده حذف شوند.
sublinear_tf=True از فرمول 1 + log(tf) به جای tf خام استفاده می‌کند. وقتی واژه‌ای در یک سند خیلی تکرار می‌شود اثر مثبت دارد.

جاهایی که TF-IDF هنوز برنده است (در سال 2026)

  • تشخیص اسپم، برچسب‌گذاری موضوعی و پرچم‌گذاری ناهنجاری در لاگ‌ها. حضور کلمات مهم است، نه ظرافت‌های معنایی.
  • شرایط کمبود داده (چند صد نمونه‌ی برچسب‌دار). ترکیب TF-IDF با رگرسیون لجستیک هیچ هزینه‌ی پیش‌آموزشی ندارد.
  • هر جایی که تأخیر (Latency) حیاتی است. ترکیب TF-IDF با یک مدل خطی در کسری از میکروثانیه جواب می‌دهد؛ امبدینگ یک سند از طریق یک ترنسفورمر 10 تا 100 میلی‌ثانیه طول می‌کشد.
  • سیستم‌هایی که باید پیش‌بینی خود را توجیه کنند. ضرایب کلاسیفایر را بررسی کنید؛ کلمات مثبت بالانشین دلیل تصمیم مدل هستند.

جاهایی که TF-IDF شکست می‌خورد

مشکل «نابینایی معنایی». این دو سند را در نظر بگیرید:

  • "The movie was not good at all."
  • "The movie was excellent."

یکی نظر منفی و دیگری مثبت است. هم‌پوشانی TF-IDF آن‌ها دقیقاً کلمات {the, movie, was} است. یک کلاسیفایر کیسه‌ی کلمات باید حفظ کند که آمدن کلمه‌ی not نزدیک good برچسب را برمی‌گرداند. مدل با داده‌ی کافی شاید این را یاد بگیرد، اما هرگز به ظرافت و زیبایی مدلی نخواهد بود که نحو زبان را می‌فهمد.

حالت شکست دیگر: کلمات خارج از دایره‌ی لغات (Out-of-vocabulary) در زمان استنتاج. مدل BoW آموزش‌دیده روی نظرات IMDb، اگر توکن Zoomer-approved در داده‌های آموزشی نیامده باشد، هیچ ایده‌ای ندارد با آن چه کند. امبدینگ‌های زیرکلمه‌ای (درس 04) از پس این کار برمی‌آیند؛ اما TF-IDF نمی‌تواند.

روش ترکیبی: امبدینگ با وزن‌دهی TF-IDF

پیش‌فرض عمل‌گرایانه‌ی سال 2026 برای کلاسیفیکیشن روی داده‌های متوسط: استفاده از وزن‌های TF-IDF به‌عنوان اتنشن روی امبدینگ کلمات.

def tfidf_weighted_embedding(doc, tfidf_scores, embedding_table, dim):
    vec = [0.0] * dim
    total_weight = 0.0
    for token in doc:
        if token not in embedding_table or token not in tfidf_scores:
            continue
        weight = tfidf_scores[token]
        emb = embedding_table[token]
        for i in range(dim):
            vec[i] += weight * emb[i]
        total_weight += weight
    if total_weight == 0:
        return vec
    return [v / total_weight for v in vec]

توان معنایی را از امبدینگ‌ها می‌گیرید و تأکید روی کلمات نادر را از TF-IDF. کلاسیفایر روی این بردار تجمیع‌شده (Pooled vector) آموزش می‌بیند. در مسائلی مثل تحلیل احساسات، تشخیص موضوع و کلاسیفیکیشن نیت، در دامنه‌ی زیر 50 هزار نمونه‌ی برچسب‌دار، این روش از هر دوی آن‌ها به تنهایی بهتر عمل می‌کند.

خروجی نهایی

این فایل را با نام outputs/prompt-vectorization-picker.md ذخیره کنید:

---
name: vectorization-picker
description: Given a text-classification task, recommend BoW, TF-IDF, embeddings, or a hybrid.
phase: 5
lesson: 02
---

You recommend a text-vectorization strategy. Given a task description, output:

1. Representation (BoW, TF-IDF, transformer embeddings, or a hybrid). Explain why in one sentence.
2. Specific vectorizer configuration. Name the library. Quote the arguments (`ngram_range`, `min_df`, `max_df`, `sublinear_tf`, `stop_words`).
3. One failure mode to test before shipping.

Refuse to recommend embeddings when the user has under 500 labeled examples unless they show evidence of semantic failure in a TF-IDF baseline. Refuse to remove stopwords for sentiment analysis (negations carry signal). Flag class imbalance as needing more than a vectorizer change.

Example input: "Classifying 30k customer support tickets into 12 categories. Most tickets are 2-3 sentences. English only. Need explainability for audit logs."

Example output:

- Representation: TF-IDF. 30k examples is not small; explainability requirement rules out dense embeddings.
- Config: `TfidfVectorizer(ngram_range=(1, 2), min_df=3, max_df=0.95, sublinear_tf=True, stop_words=None)`. Keep stopwords because category keywords sometimes are stopwords ("not working" vs "working").
- Failure to test: verify `min_df=3` does not drop rare category keywords. Run `get_feature_names_out` filtered by class and eyeball.

تمرین‌ها

  1. ساده. تابع cosine_similarity(doc_vec_a, doc_vec_b) را روی خروجی نرمال‌سازی‌شده‌ی L2 در TF-IDF پیاده‌سازی کنید. بررسی کنید که امتیاز اسناد یکسان 1.0 و اسنادی با واژگان کاملاً مجزا 0.0 شود.
  2. متوسط. قابلیت پشتیبانی از n-gram را به تابع bag_of_words اضافه کنید. پارامتر n باید شمارش را روی دنباله‌های nتایی انجام دهد. بررسی کنید که اجرای n=2 روی ورودی ["the", "cat", "sat"]، شمارش بای‌گرام‌ها را برای ["the cat", "cat sat"] تولید کند.
  3. سخت. روش ترکیبی «امبدینگ با وزن‌دهی TF-IDF» را با بردارهای GloVe 100d بسازید (یک بار دانلود و کش کنید). دقت کلاسیفیکیشن را با TF-IDF ساده و امبدینگ میانگین‌گیری‌شده‌ی ساده (Mean-pooled) روی مجموعه‌داده‌ی 20 Newsgroups مقایسه کنید. گزارش دهید که هر روش در کجا برنده می‌شود.

اصطلاحات کلیدی

اصطلاح در گفتار رایج معنای دقیق فنی
BoW بردار بسامد کلمات شمارش کلمات فرهنگ لغت در یک سند؛ ترتیب کلمات را دور می‌ریزد.
TF بسامد واژه تعداد دفعات تکرار یک کلمه در سند، که به‌دلخواه با طول سند نرمال می‌شود.
DF بسامد سندی تعداد اسنادی که این کلمه حداقل یک بار در آن‌ها آمده است.
IDF معکوس بسامد سندی فرمول هموارشده‌ی log(N / df)؛ وزن کلماتی را که همه‌جا می‌آیند پایین می‌آورد.
بردار خلوت (Sparse vector) اکثراً صفر فرهنگ لغت معمولاً بین 10 تا 100 هزار کلمه دارد؛ بیشتر آن‌ها در هر سند مشخصی غایب‌اند.
شباهت کسینوسی زاویه‌ی بردارها ضرب داخلی بردارهای نرمال‌سازی‌شده با L2؛ مقدار 1 یعنی کاملاً منطبق و 0 یعنی عمود بر هم (متعامد).

برای مطالعه‌ی بیشتر

خودآزمایی

به سؤال‌ها جواب بدهید تا ببینید چقدر از درس در ذهنتان مانده است.

  1. پیش از درسکیسه کلمات (Bag of Words) چه چیزی را نادیده می‌گیرد و حذف می‌کند؟
  2. پیش از درسچرا TF را با فاکتور IDF مقیاس‌بندی (اسکیل) می‌کنیم؟
  3. حین درسدر فرمول هموارسازی‌شده‌ی IDF یعنی log((N+1)/(df+1)) + 1، مقدار 1+ انتهایی چه چیزی را تضمین می‌کند؟
  4. حین درسچرا سطرهای TF-IDF را پیش از محاسبه شباهت کسینوسی با نرم L2 نرمال‌سازی می‌کنیم؟
  5. حین درسفعال کردن کدام تنظیم در TfidfVectorizer برای تحلیل احساسات (sentiment analysis) ریسک دارد؟
  6. پس از درسروش TF-IDF هنوز در سال 2026 در کدام تسک برنده است؟
  7. پس از درسچرا TF-IDF روی جفت‌جمله 'The movie was not good' در برابر 'The movie was excellent' شکست می‌خورد؟
  8. پس از درسترکیب هیبریدی امبدینگ با وزن‌دهی TF-IDF چیست؟

سؤالات متداول

تفاوت Bag of Words با TF-IDF چیست؟

در Bag of Words تنها تعداد دفعات تکرار کلمات در سند شمرده می‌شود، اما در TF-IDF وزن کلماتی که در تمام اسناد تکرار شده‌اند (مانند حروف اضافه) کاهش یافته و وزن واژه‌های تمایزبخش افزایش می‌یابد.

چه زمانی باید از TF-IDF به‌جای امبدینگ‌های مدرن استفاده کرد؟

در مواقعی که حجم داده‌های برچسب‌دار محدود است، تأخیر استنتاج بر حسب میکروثانیه اهمیت دارد، فقط حضور کلمات مهم است یا سیستم باید تصمیمات خود را شفاف و تفسیرپذیر توضیح دهد.

نقاط ضعف اصلی TF-IDF کدامند؟

این روش نسبت به ترتیب واژه‌ها و معنای متن نابیناست (تفاوت منفی‌سازها یا شباهت‌های معنایی را درک نمی‌کند) و نمی‌تواند کلماتی را که در مرحله آموزش وجود نداشته‌اند (Out-of-vocabulary) مدیریت کند.

ما رو توی گوگل بیشتر ببین

با افزودن نکست ادمین به «منابع ترجیحی» گوگل، درس‌ها و مطالب ما را در بخش اخبار برتر جستجو بیشتر می‌بینید.