پیش از مقایسهٔ بردارِ یک پرسش با سندها، باید روشن کنیم وزن‌ها و ترتیبِ مؤلفه‌ها از کجا آمده‌اند. اگر هر پرسش دوباره واژگان و وزن‌ها را بسازد، اعدادِ حاصل لزوماً در همان فضای قبلی نیستند. این درس، برازشِ یک مدلِ کوچکِ TF-IDF و تبدیلِ ورودی‌های تازه با همان مدل را جدا می‌کند. داده‌ها از قبل به توکن تقسیم شده‌اند؛ موتورِ جست‌وجو، مدلِ زبانی یا ارزیابیِ کیفیت اجرا نمی‌شود.

۱. شمارشِ واژه را از فراوانیِ سند جدا کنیم

تکرار در یک سند با حضور در چند سند فرق دارد
تصویر مفهومی: تکرار در یک سند با حضور در چند سند فرق دارد؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

سه سندِ تألیفی داریم: سندِ اول relay، relay و sensor؛ سندِ دوم sensor و valve؛ سندِ سوم تهی است. شمارشِ relay در سندِ اول دو است، ولی فقط در یک سند حضور دارد. sensor یک بار در هر یک از دو سند دیده می‌شود. همین تفاوتِ «چند بار» و «در چند سند» مبنای محاسبه است.

بخشِ TF-IDF کتابِ بازیابیِ اطلاعاتِ استنفورد ترکیبِ فراوانیِ درونِ سند با وزنِ وابسته به فراوانیِ سندی را توضیح می‌دهد. در کدِ ما tf همان شمارشِ خام است؛ فراوانیِ نسبی، لگاریتمِ شمارش یا احتمالِ رخداد نیست. df نیز هر سندِ دارای توکن را فقط یک بار می‌شمارد.

واژه‌های مثال فقط برچسبِ ویژگی‌اند و واحدِ فیزیکی ندارند. تعدادِ توکنِ نوشته‌شده، اندازه‌گیریِ حسگر یا زمانِ پردازش نیست. سندِ تهی در تعدادِ سندهای آموزش شمرده می‌شود، ولی به df هیچ توکنی اضافه نمی‌کند. بنابراین حذفِ آن، صرفاً حذفِ یک سطرِ بی‌اثر از همهٔ محاسبات نیست.

اینجا «توکن» یعنی یک رشتهٔ صریحِ ورودی، نه الزاماً واژهٔ زبان طبیعی. رشتهٔ دارای فاصله نیز می‌تواند یک توکنِ واحد باشد. برنامه split، تبدیلِ حروف، ریشه‌یابی یا یکسان‌سازیِ نیم‌فاصله انجام نمی‌دهد. تصمیمِ جداسازیِ متن باید پیش از ورود به این رابط گرفته شود و در آموزش و پرسش سازگار بماند.

۲. واژگان و IDF را فقط از مجموعهٔ آموزش بسازیم

واژگان در مرحلهٔ برازش ثابت می‌شود
تصویر مفهومی: واژگان در مرحلهٔ برازش ثابت می‌شود؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

واژگان از اجتماعِ توکن‌های آموزش ساخته و با ترتیبِ رشته‌های پایتون ثابت می‌شود: relay، sensor و valve. این ترتیب، الفبای زبان‌شناختی یا ترتیبِ اهمیت نیست؛ فقط پایهٔ مشترکِ بردارهاست. برای سه سندِ حاضر، df به ترتیب یک، دو و یک است. تکرارِ relay در همان سند، مؤلفهٔ نخستِ df را دو نمی‌کند.

قراردادِ وزن را صریح انتخاب می‌کنیم: لگاریتمِ طبیعیِ نسبتِ یک‌به‌علاوهٔ تعدادِ سندها به یک‌به‌علاوهٔ df، سپس جمع با یک. مرجعِ TfidfTransformer این شکلِ هموارسازی و جداییِ fit از transform را شرح می‌دهد. همهٔ قراردادهای TF-IDF یکسان نیستند؛ فرمولِ نوشته‌شده جزئی از تعریفِ همین آزمایش است.

برای relay و valve وزن حدود ۱٫۶۹۳۱۴۷ و برای sensor حدود ۱٫۲۸۷۶۸۲ به دست می‌آید. اگر توکنی در تمام سندهای آموزش باشد، با این قرارداد IDF آن یک می‌شود، نه صفر. افزودنِ یک در پایانِ فرمول را نباید هنگام مقایسه با تعریفِ دیگری از IDF فراموش کرد.

روشِ fit یک شیء تازه با واژگان، df، IDF و تعدادِ سندِ ثابت می‌سازد. transform فقط از همین اطلاعات استفاده می‌کند. فراوان شدنِ یک توکن در پرسش، آمارِ آموزش را بازنویسی نمی‌کند. این جدایی به معنیِ اثباتِ تعمیمِ مدل نیست؛ فقط اجازه نمی‌دهد دریافتِ پرسش بی‌صدا پایهٔ نمایشِ قبلی را عوض کند.

۳. کد کاملِ تبدیلِ پرسش با مدلِ ثابت

پرسش با همان ظرفِ ثابت تبدیل می‌شود
تصویر مفهومی: پرسش با همان ظرفِ ثابت تبدیل می‌شود؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

فایل را با نام tfidf_frozen_vocabulary.py ذخیره و با python tfidf_frozen_vocabulary.py اجرا کنید. اجرای ثبت‌شده Python 3.14.5 و فقط کتابخانهٔ استاندارد دارد. scikit-learn در برنامه فراخوانی نمی‌شود؛ این یک رابطِ کوچکِ تألیفی است و سازگاریِ کامل با API، ورودیِ ماتریسی یا خروجیِ تنکِ آن کتابخانه را ادعا نمی‌کند.

"""Own bounded pretokenized TF-IDF fit/transform; no text tokenizer or ML run."""
from collections import Counter
from dataclasses import dataclass, FrozenInstanceError
from decimal import Decimal, localcontext
from itertools import product
import json
import math
import platform

MAX_DOCS = 64
MAX_TOKENS = 256
MAX_VOCABULARY = 128


def checked_document(document):
    if type(document) is not tuple:
        raise TypeError('each pretokenized document must be an actual tuple')
    if len(document) > MAX_TOKENS:
        raise ValueError('own document cap is 256 token occurrences')
    if any(type(token) is not str or token == '' for token in document):
        raise ValueError('actual nonempty string tokens required; no normalization')
    return document


@dataclass(frozen=True)
class TfidfModel:
    vocabulary: tuple
    document_frequency: tuple
    idf: tuple
    n_documents: int

    @classmethod
    def fit(cls, documents):
        if type(documents) not in (tuple, list):
            raise TypeError('finite list or tuple of documents required')
        if not 1 <= len(documents) <= MAX_DOCS:
            raise ValueError('own training cap is 1..64 documents')
        counts = tuple(Counter(checked_document(doc)) for doc in documents)
        vocabulary = tuple(sorted({token for count in counts for token in count}))
        if not 1 <= len(vocabulary) <= MAX_VOCABULARY:
            raise ValueError('own vocabulary cap is 1..128 distinct tokens')
        n = len(counts)
        df = tuple(sum(token in count for count in counts) for token in vocabulary)
        idf = tuple(math.log((1 + n) / (1 + frequency)) + 1.0 for frequency in df)
        return cls(vocabulary, df, idf, n)

    def transform(self, document):
        # Instances built by fit only; malformed direct construction unsupported.
        counts = Counter(checked_document(document))
        weighted = tuple(counts.get(token, 0) * weight for token, weight in zip(self.vocabulary, self.idf))
        norm = math.hypot(*weighted)
        return tuple(value / norm for value in weighted) if norm else tuple(0.0 for _ in weighted)


def decimal_reference(documents, query):
    # Direct membership/count logic and independent high-precision arithmetic;
    # no TfidfModel, Counter, math.log or math.hypot call creates expected values.
    vocabulary = tuple(sorted(set(token for document in documents for token in document)))
    frequencies = tuple(sum(1 for document in documents if token in document) for token in vocabulary)
    with localcontext() as context:
        context.prec = 60
        weights = tuple((Decimal(1 + len(documents)) / Decimal(1 + df)).ln() + Decimal(1) for df in frequencies)
        values = tuple(Decimal(query.count(token)) * weight for token, weight in zip(vocabulary, weights))
        norm = sum(value * value for value in values).sqrt()
        vector = tuple(value / norm for value in values) if norm else tuple(Decimal(0) for _ in values)
        return vocabulary, frequencies, tuple(float(x) for x in weights), tuple(float(x) for x in vector)


def close_vector(a, b):
    return len(a) == len(b) and all(math.isclose(x, y, rel_tol=2e-14, abs_tol=2e-14) for x, y in zip(a, b))


def rejects(call):
    try:
        call()
    except (TypeError, ValueError, FrozenInstanceError):
        return True
    return False


def run():
    corpus = [('relay', 'relay', 'sensor'), ('sensor', 'valve'), ()]
    model = TfidfModel.fit(corpus)
    query = ('relay', 'new', 'sensor')
    vectors = tuple(model.transform(document) for document in corpus)
    query_vector = model.transform(query)
    checks = {}
    checks['hand_vocabulary_df_and_empty_training_row_count'] = (
        model.vocabulary == ('relay', 'sensor', 'valve') and model.document_frequency == (1, 2, 1) and model.n_documents == 3)
    checks['hand_smoothed_idf_constants'] = close_vector(model.idf, (1.6931471805599453, 1.2876820724517808, 1.6931471805599453))
    checks['nonzero_vectors_unit_L2_and_empty_vector_zero'] = (
        all(math.isclose(math.hypot(*v), 1.0, abs_tol=2e-14) for v in vectors[:2])
        and vectors[2] == (0.0, 0.0, 0.0) and all(math.isfinite(x) for v in vectors for x in v))
    checks['raw_term_count_not_document_frequency'] = math.isclose(vectors[0][0] / vectors[0][1], 2 * model.idf[0] / model.idf[1], rel_tol=2e-14)
    before = (model.vocabulary, model.document_frequency, model.idf, model.n_documents)
    model.transform(('new', 'new', 'sensor'))
    checks['query_transform_never_refits_training_state'] = before == (model.vocabulary, model.document_frequency, model.idf, model.n_documents)
    checks['OOV_ignored_and_all_OOV_or_empty_query_zero'] = (
        query_vector == model.transform(('relay', 'sensor')) and model.transform(('new',)) == (0.0, 0.0, 0.0)
        and model.transform(()) == (0.0, 0.0, 0.0))
    checks['training_order_invariance_with_fixed_sorted_features'] = TfidfModel.fit(list(reversed(corpus))) == model
    refitted = TfidfModel.fit(corpus + [('relay',)])
    checks['refit_is_new_model_with_changed_df_and_idf'] = (
        refitted.document_frequency == (2, 2, 1) and refitted.n_documents == 4
        and refitted.idf != model.idf and model.n_documents == 3)
    with_empty = TfidfModel.fit(corpus + [()])
    checks['extra_empty_training_document_changes_n_not_df'] = (
        with_empty.n_documents == 4 and with_empty.document_frequency == model.document_frequency and with_empty.idf != model.idf)
    common = TfidfModel.fit([('a', 'b'), ('a',)])
    checks['term_in_every_training_document_has_smoothed_idf_one'] = common.idf[0] == 1.0 and common.idf[1] > 1.0
    class StringChild(str):
        pass
    checks['exact_types_nonempty_tokens_and_all_empty_fit_policy'] = all([
        rejects(lambda: TfidfModel.fit(iter(corpus))), rejects(lambda: TfidfModel.fit([['a']])),
        rejects(lambda: TfidfModel.fit([])), rejects(lambda: TfidfModel.fit([(), ()])),
        rejects(lambda: TfidfModel.fit([('',)])), rejects(lambda: TfidfModel.fit([(True,)])),
        rejects(lambda: model.transform(["relay"])), rejects(lambda: model.transform((StringChild('relay'),))),
    ])
    large_document = tuple('t' + str(i) for i in range(MAX_VOCABULARY))
    boundary = TfidfModel.fit([large_document] * MAX_DOCS)
    checks['own_document_token_and_vocabulary_caps_inclusive'] = (
        len(boundary.vocabulary) == 128 and boundary.n_documents == 64
        and boundary.transform(('t0',) * MAX_TOKENS)[0] == 1.0
        and rejects(lambda: TfidfModel.fit([('a',)] * (MAX_DOCS + 1)))
        and rejects(lambda: model.transform(('relay',) * (MAX_TOKENS + 1)))
        and rejects(lambda: TfidfModel.fit([large_document + ('extra',)])))
    raw = ('\u00e9', 'e\u0301', '\u06cc', '\u064a', '\U0001f642', 'a b', 'a', 'b')
    unicode_model = TfidfModel.fit([raw])
    checks['raw_Unicode_and_pretokenized_spaces_remain_distinct'] = (
        len(unicode_model.vocabulary) == 8 and unicode_model.transform(('\u00e9',)) != unicode_model.transform(('e\u0301',))
        and unicode_model.transform(('a b',)) != unicode_model.transform(('a', 'b')))
    corpus.append(('new',))
    checks['outer_input_mutation_and_model_assignment_do_not_refit'] = (
        model.n_documents == 3 and 'new' not in model.vocabulary
        and rejects(lambda: setattr(model, 'n_documents', 99)))
    patterns = ((), ('a',), ('b',), ('a', 'a'), ('a', 'b'), ('b', 'b'))
    queries = patterns + (('z',), ('a', 'z', 'b'))
    oracle_cases = valid_corpora = 0
    oracle_ok = True
    for n in range(1, 4):
        for documents in product(patterns, repeat=n):
            if not any(documents):
                continue
            candidate = TfidfModel.fit(documents)
            valid_corpora += 1
            for question in queries:
                vocabulary, frequencies, idf, vector = decimal_reference(documents, question)
                oracle_ok &= (candidate.vocabulary == vocabulary and candidate.document_frequency == frequencies
                              and close_vector(candidate.idf, idf) and close_vector(candidate.transform(question), vector))
                oracle_cases += 1
    checks['2040_Decimal_queries_over_255_corpora_ONE_oracle_group'] = bool(oracle_ok) and valid_corpora == 255 and oracle_cases == 2040
    assert all(type(value) is bool and value for value in checks.values()), checks
    return {'python': platform.python_version(), 'dependencies': 'standard library only',
            'scope': 'bounded pretokenized fit/transform, no tokenizer, sklearn execution, ranking or quality experiment',
            'formula': 'idf = ln((1+n)/(1+df)) + 1; raw tf; L2 rows', 'vocabulary': model.vocabulary,
            'training_n_documents': model.n_documents, 'document_frequency': model.document_frequency, 'idf': model.idf,
            'training_vectors': vectors, 'query_tokens': query, 'query_vector': query_vector,
            'all_OOV_query_vector': model.transform(('new',)), 'refit_document_frequency': refitted.document_frequency,
            'refit_idf': refitted.idf, 'valid_corpora_inside_one_oracle_group': valid_corpora,
            'Decimal_query_comparisons_inside_one_oracle_group': oracle_cases, 'checks': checks}


if __name__ == '__main__':
    print(json.dumps(run(), ensure_ascii=False, indent=2))

فهرستِ بیرونیِ آموزش باید list یا tuple واقعی باشد؛ هر سند، tuple واقعی از رشته‌های واقعیِ ناتهی است. سندِ تهی مجاز است، ولی آموزشِ کاملاً بی‌واژگان رد می‌شود. برای این تمرین یک تا شصت‌وچهار سند، حداکثر ۲۵۶ رخدادِ توکن در هر ورودی و حداکثر ۱۲۸ توکنِ متمایز داریم. این‌ها حدهای آموزشی‌اند، نه حدهای TF-IDF.

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

شیء را فقط با fit می‌سازیم؛ ساختِ مستقیمِ dataclass با دادهٔ ناسازگار پشتیبانی نمی‌شود. واژگان و وزن‌ها tuple و خودِ شیء frozen است. افزودنِ سند به فهرستِ بیرونیِ قبلی، مدل را تغییر نمی‌دهد. این ویژگی را با نگهداریِ فایل، نسخه‌بندیِ پایدار یا حفاظتِ هم‌زمانِ یک سامانهٔ کامل یکسان معرفی نمی‌کنیم.

۴. بردارِ صفر و مرجعِ اعشاریِ مستقل را بررسی کنیم

ورودیِ بدون واژهٔ شناخته‌شده
تصویر مفهومی: ورودیِ بدون واژهٔ شناخته‌شده؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

پرسشِ نمونه relay، new و sensor دارد. new در واژگانِ آموزش نیست و در این قرارداد نادیده گرفته می‌شود. خروجی به ترتیبِ ثابتِ ویژگی‌ها تقریباً ۰٫۷۹۵۹۶۱، ۰٫۶۰۵۳۴۹ و صفر است. همان پرسش بدونِ new دقیقاً همان بردار را می‌دهد؛ حضورِ یک رشتهٔ ناشناخته، ستونِ تازه یا IDF تازه ایجاد نمی‌کند.

{
  "python": "3.14.5",
  "dependencies": "standard library only",
  "scope": "bounded pretokenized fit/transform, no tokenizer, sklearn execution, ranking or quality experiment",
  "formula": "idf = ln((1+n)/(1+df)) + 1; raw tf; L2 rows",
  "vocabulary": [
    "relay",
    "sensor",
    "valve"
  ],
  "training_n_documents": 3,
  "document_frequency": [
    1,
    2,
    1
  ],
  "idf": [
    1.6931471805599454,
    1.2876820724517808,
    1.6931471805599454
  ],
  "training_vectors": [
    [
      0.9347019636214325,
      0.3554324678504174,
      0.0
    ],
    [
      0.0,
      0.6053485081062916,
      0.7959605415681652
    ],
    [
      0.0,
      0.0,
      0.0
    ]
  ],
  "query_tokens": [
    "relay",
    "new",
    "sensor"
  ],
  "query_vector": [
    0.7959605415681652,
    0.6053485081062916,
    0.0
  ],
  "all_OOV_query_vector": [
    0.0,
    0.0,
    0.0
  ],
  "refit_document_frequency": [
    2,
    2,
    1
  ],
  "refit_idf": [
    1.5108256237659907,
    1.5108256237659907,
    1.916290731874155
  ],
  "valid_corpora_inside_one_oracle_group": 255,
  "Decimal_query_comparisons_inside_one_oracle_group": 2040,
  "checks": {
    "hand_vocabulary_df_and_empty_training_row_count": true,
    "hand_smoothed_idf_constants": true,
    "nonzero_vectors_unit_L2_and_empty_vector_zero": true,
    "raw_term_count_not_document_frequency": true,
    "query_transform_never_refits_training_state": true,
    "OOV_ignored_and_all_OOV_or_empty_query_zero": true,
    "training_order_invariance_with_fixed_sorted_features": true,
    "refit_is_new_model_with_changed_df_and_idf": true,
    "extra_empty_training_document_changes_n_not_df": true,
    "term_in_every_training_document_has_smoothed_idf_one": true,
    "exact_types_nonempty_tokens_and_all_empty_fit_policy": true,
    "own_document_token_and_vocabulary_caps_inclusive": true,
    "raw_Unicode_and_pretokenized_spaces_remain_distinct": true,
    "outer_input_mutation_and_model_assignment_do_not_refit": true,
    "2040_Decimal_queries_over_255_corpora_ONE_oracle_group": true
  }
}

پرسشِ فقط شاملِ new و پرسشِ تهی هر دو بردارِ صفر می‌دهند. این نتیجه می‌گوید در نمایشِ انتخاب‌شده وزنِ شناخته‌شده‌ای نمانده است؛ بی‌معنا بودنِ پرسش یا نبودِ سندِ مرتبط را ثابت نمی‌کند. اگر مصرف‌کننده بخواهد کسینوس بگیرد، سیاستِ بردارِ صفر باید همچنان روشن باشد؛ این برنامه خودش رتبه‌بندی نمی‌کند.

مرجعِ مستقل، حضورِ توکن را مستقیم در سندها و شمارشِ پرسش را با count می‌سنجد. لگاریتم و جذر را با Decimal و دقتِ شصت‌رقمی محاسبه می‌کند؛ از مدل، Counter، math.log یا math.hypot برای تولیدِ انتظار استفاده نمی‌کند. مقایسه با تلورانس انجام می‌شود؛ نمایشِ دودوییِ float قرار نیست با بسطِ اعشاریِ دقیق یکسان باشد.

دویست‌وپنجاه‌وپنج مجموعهٔ آموزشِ کوچک و هشت پرسش برای هرکدام، ۲۰۴۰ مقایسه می‌سازند. همهٔ آن‌ها یک گروهِ مرجع‌اند؛ تعدادِ واقعیِ گروه‌های نام‌دار پانزده است. شاهدهای دیگر، توکنِ مشترک، ترتیبِ آموزش، واژهٔ ناشناخته، مرزِ نوع‌ها، سقف‌ها و تمایزِ خامِ نویسه‌های یونیکد را می‌سنجند. این اجرا آزمونِ پیاده‌سازی است، نه سنجشِ کیفیتِ بازیابی.

درس شباهتِ کسینوسیِ بردارهای سند از بردارهای دستی آغاز می‌کند؛ اینجا خودِ وزن و پایهٔ تبدیل ساخته می‌شود. در نمایشِ سند با SVD بریده نیز کاهشِ رتبه موضوعِ اصلی است. در این برنامه نه مؤلفه‌ای با SVD حذف می‌شود و نه ادعای برتری نسبت به آن روش‌ها داریم.

۵. چهار پرسش دربارهٔ واژهٔ ناشناخته و بازبرازش

بازبرازش قراردادِ تازه‌ای می‌سازد
تصویر مفهومی: بازبرازش قراردادِ تازه‌ای می‌سازد؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

پرسش اول: چرا df توکنِ relay برابر دو نیست؟ پاسخ: دو رخداد در یک سند داریم. df شمارِ سندهای دارای توکن است، پس یک می‌شود؛ در مقابل، tf همان سند دو است. جای این دو مقدار را در فرمول عوض نمی‌کنیم.

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

پرسش سوم: افزودنِ یک سندِ تهی بی‌اثر است؟ پاسخ: خیر؛ تعدادِ سندها زیاد ولی df ثابت می‌ماند، پس IDF می‌تواند عوض شود. آنچه ثابت می‌ماند مدلِ قبلی است؛ محاسبهٔ وزن‌های تازه نیاز به fit تازه دارد و قراردادِ نمایشِ تازه می‌سازد.

پرسش چهارم: افزودنِ relay به آموزش، همان transform است؟ پاسخ: خیر؛ در بازبرازشِ شاهد، تعدادِ سندها چهار و df برابر دو، دو و یک می‌شود. منابع در ۲۶ مهر ۱۴۰۵ خوانده شدند؛ نسخهٔ مستندات scikit-learn برابر 1.9.1 است و اجرای آن کتابخانه یا بهبودِ کیفیت از این مثال نتیجه نمی‌شود.

منابع و مطالعهٔ بیشتر

بررسی منابع: ۲۶ مهر ۱۴۰۵. تحلیل‌ها و پیشنهادهای اجرایی، جمع‌بندی نویسنده‌اند.