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

دو کلاس با نامهای electrical و software میسازیم. دو نمونهٔ اول بهترتیب واژههای موتور و جریان، سپس موتور و ولتاژ دارند؛ در کد، این سه واژه با motor/current/voltage نمایش داده میشوند. دو نمونهٔ نرمافزار نیز پایتون و کد، سپس پایتون و خطا دارند. برچسبها بخشی از دادهٔ آموزشیِ تألیفیاند و از مدل به دست نیامدهاند.
واژگان فقط از همین چهار نمونه استخراج میشود و شش عضو دارد. ورود پرسش تازه، عضو هفتمی به آن اضافه نمیکند. هر کلاس دو سند آموزشی دارد؛ پس احتمال پیشین هرکدام یکدوم است. این شمارِ سندها را با جمعِ وقوع واژهها اشتباه نگیریم: در هر کلاسِ این مثال، چهار وقوع داریم، اما این برابریِ تصادفی، تعریف پیشین را عوض نمیکند.
فصل Naive Bayes در کتاب بازیابی اطلاعات Stanford پیشین کلاس و شمارش وقوعهای واژه را جدا تعریف میکند. در درس وزندهی BM25، مسئله رتبهبندی سندهای موجود بود؛ اینجا از برچسب آموزشی، پارامترهای یک طبقهبند حوزه را تخمین میزنیم و هنوز سند پاسخ را بازیابی نکردهایم.
۲. شمارش کلاسی و هموارسازی لاپلاس را حساب کنیم

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

مسیر اصلی بهجای ضربِ زنجیرهای، لگاریتم پیشین و لگاریتم عوامل واژه را جمع میکند. شمارِ تکرار پرسش ضریبِ همان لگاریتم است. برای ساخت احتمال نهایی، بزرگترین امتیاز را از همه کم میکنیم، نمایی میگیریم و بر جمع وزنها تقسیم میکنیم. این جابهجایی، مقیاس مشترک را حذف میکند؛ معنای آماری تازهای به نتیجه نمیافزاید.
برخلاف سیاستِ پرسش در درس BM25، تکرار اینجا حذف نمیشود. پرسشِ موتور، موتور برای برق نهدهم میدهد؛ دو وقوع در حاصلضرب شرکت دارند. ترتیب واژهها در این مدل، ویژگی جدا نیست. جمعِ دقیقترِ شناور با fsum انجام میشود، اما محاسبه همچنان شناور است و حساب کسریِ دقیق معرفی نمیشود.
واژهٔ laser بیرون از واژگان است. آن را در فهرست ناشناختههای خروجی نگه میداریم، ولی عاملی به امتیاز اضافه نمیکنیم. اگر هیچ واژهٔ شناختهشدهای باقی نماند، برچسب انتخابی تهی است؛ پیشینها فقط برای مشاهده گزارش میشوند. این امتناع، سیاست آموزشی ماست، نه نتیجهٔ اجباریِ فرمول طبقهبندی.
در پرسش موتور و پایتون، امتیازهای این نمونه دقیقاً برابر میشوند؛ ترتیب رشتهایِ نام کلاس، برق را جلو میگذارد. فقط برابری دقیق امتیازهای شناور چنین شکسته میشود؛ بازهٔ تلورانس برای ادغام امتیازهای نزدیک نداریم. این قرارداد، تضمین نمیکند هر تساویِ ریاضی در همهٔ دادهها پس از گردکردن شناور هم دقیق باقی بماند.
۴. کد را با ضرب کسریِ مستقل بسنجیم

فایل naive_bayes_query_routing.py با Python 3.14.5 و کتابخانهٔ استاندارد اجرا شده است. مرحلهٔ آموزش پس از بررسی تمام ردیفها، مقدارِ تغییرناپذیری از تاپلها میسازد. تغییر فهرستِ ورودیِ فراخواننده، شمارشهای مدلِ ساختهشده را تغییر نمیدهد. ورودی مدلِ امتیازدهی، خروجی همین سازنده است؛ فایل مدلِ ناشناخته را اعتبارسنجی نمیکنیم.
"""A bounded, original multinomial count model; no network or ML package."""
from collections import Counter
from dataclasses import dataclass
from fractions import Fraction
from itertools import product
import json
import math
import platform
import re
def word(value):
if type(value) is not str or re.fullmatch(r"[a-z]{1,24}", value) is None:
raise ValueError("expected 1..24 lowercase ASCII letters")
return value
def words(value, limit, allow_empty=False):
if type(value) not in (list, tuple) or not (0 if allow_empty else 1) <= len(value) <= limit:
raise ValueError("token sequence outside contract")
return tuple(word(x) for x in value)
@dataclass(frozen=True)
class CountModel:
classes: tuple
vocabulary: tuple
document_counts: tuple
token_counts: tuple
def fit_count_model(records):
if type(records) not in (list, tuple) or not 1 <= len(records) <= 16:
raise ValueError("expected 1..16 labeled documents")
rows = []
for row in records:
if type(row) not in (list, tuple) or len(row) != 2:
raise ValueError("expected (class, tokens)")
rows.append((word(row[0]), words(row[1], 16)))
classes = tuple(sorted({label for label, _ in rows}))
vocabulary = tuple(sorted({token for _, tokens in rows for token in tokens}))
if not 2 <= len(classes) <= 4 or len(vocabulary) > 64:
raise ValueError("expected 2..4 classes and at most 64 vocabulary terms")
doc_counts = []
token_counts = []
for label in classes:
chosen = [tokens for current, tokens in rows if current == label]
counter = Counter(token for tokens in chosen for token in tokens)
doc_counts.append(len(chosen))
token_counts.append(tuple(counter[token] for token in vocabulary))
return CountModel(classes, vocabulary, tuple(doc_counts), tuple(token_counts))
def score_query(model, query):
# The model is the immutable value returned by fit_count_model, not a serialized input.
if type(model) is not CountModel:
raise ValueError("expected fitted CountModel")
query = words(query, 128, allow_empty=True)
vocabulary = set(model.vocabulary)
known = tuple(token for token in query if token in vocabulary)
unknown = tuple(token for token in query if token not in vocabulary)
frequencies = Counter(known)
positions = {token: index for index, token in enumerate(model.vocabulary)}
scores = []
for label, documents, counts in zip(model.classes, model.document_counts, model.token_counts):
terms = [math.log(documents / sum(model.document_counts))]
denominator = sum(counts) + len(model.vocabulary)
for token, frequency in frequencies.items():
terms.append(frequency * math.log((counts[positions[token]] + 1) / denominator))
log_joint = math.fsum(terms)
scores.append((label, log_joint))
maximum = max(log_joint for _, log_joint in scores)
scaled = [(label, log_joint, math.exp(log_joint - maximum)) for label, log_joint in scores]
total = math.fsum(weight for _, _, weight in scaled)
ranked = sorted(
[dict(label=label, log_joint=log_joint, probability=weight / total)
for label, log_joint, weight in scaled],
key=lambda row: (-row["log_joint"], row["label"]),
)
return dict(
selected_label=ranked[0]["label"] if known else None,
reason="model_score" if known else "no_known_terms",
known_tokens=list(known), unknown_tokens=list(unknown), ranking=ranked,
)
def exact_reference(records, query):
"""Independent direct products of exact counts; no fitted model or log scorer."""
labels = sorted(set(row[0] for row in records))
vocabulary = set(token for _, tokens in records for token in tokens)
masses = {}
for label in labels:
documents = [tokens for current, tokens in records if current == label]
mass = Fraction(len(documents), len(records))
token_total = sum(len(document) for document in documents)
for token in query:
if token in vocabulary:
occurrences = sum(document.count(token) for document in documents)
mass *= Fraction(occurrences + 1, token_total + len(vocabulary))
masses[label] = mass
denominator = sum(masses.values())
return {label: mass / denominator for label, mass in masses.items()}
def rejected(function, *args):
try:
function(*args)
except ValueError:
return True
return False
def close(actual, expected):
return math.isclose(actual, float(expected), rel_tol=1e-12, abs_tol=1e-12)
def probabilities(result):
return {row["label"]: row["probability"] for row in result["ranking"]}
def run_example():
records = [("electrical", ["motor", "current"]), ("electrical", ["motor", "voltage"]),
("software", ["python", "code"]), ("software", ["python", "error"])]
model = fit_count_model(records)
answer = score_query(model, ["motor", "current"])
checks = {}
checks["literal_class_priors_vocabulary_and_counts"] = (
model.classes == ("electrical", "software") and model.document_counts == (2, 2)
and model.vocabulary == ("code", "current", "error", "motor", "python", "voltage")
and model.token_counts == ((0, 1, 0, 2, 0, 1), (1, 0, 1, 0, 2, 0)))
checks["hand_joint_masses_normalize_to_six_sevenths"] = (
close(probabilities(answer)["electrical"], Fraction(6, 7))
and close(answer["ranking"][0]["log_joint"], math.log(3 / 100))
and close(answer["ranking"][1]["log_joint"], math.log(1 / 200)))
checks["repeated_query_terms_are_not_deduplicated"] = close(
probabilities(score_query(model, ["motor", "motor"]))["electrical"], Fraction(9, 10))
checks["query_order_does_not_change_scores"] = all(
close(probabilities(score_query(model, ["current", "motor"]))[label], probability)
for label, probability in probabilities(answer).items())
tied = score_query(model, ["motor", "python"])
checks["exact_fixture_tie_uses_lexicographic_label"] = (
tied["ranking"][0]["log_joint"] == tied["ranking"][1]["log_joint"]
and tied["selected_label"] == "electrical"
and all(close(row["probability"], Fraction(1, 2)) for row in tied["ranking"]))
mixed = score_query(model, ["motor", "laser", "current", "laser"])
checks["unknown_terms_are_reported_and_do_not_expand_vocabulary"] = (
mixed["unknown_tokens"] == ["laser", "laser"]
and mixed["known_tokens"] == ["motor", "current"]
and probabilities(mixed) == probabilities(answer) and "laser" not in model.vocabulary)
checks["empty_and_all_unknown_queries_abstain_but_expose_priors"] = all(
score_query(model, query)["selected_label"] is None
and score_query(model, query)["reason"] == "no_known_terms"
and all(close(row["probability"], Fraction(1, 2))
for row in score_query(model, query)["ranking"])
for query in ([], ["laser"]))
more = records + [("electrical", ["motor", "current"])]
imbalanced = fit_count_model(more)
checks["empirical_prior_counts_documents_not_tokens"] = (
imbalanced.document_counts == (3, 2)
and close(probabilities(score_query(imbalanced, []))["electrical"], Fraction(3, 5)))
checks["duplicate_training_rows_have_explicit_count_weight"] = (
imbalanced.token_counts[0] == (0, 2, 0, 3, 0, 1)
and close(probabilities(score_query(imbalanced, ["motor", "current"]))["electrical"],
exact_reference(more, ["motor", "current"])["electrical"]))
before = repr(records)
score_query(model, ["motor"])
records[0][1].append("laser")
checks["fitted_tuples_are_detached_from_mutable_training_input"] = (
before != repr(records) and "laser" not in model.vocabulary
and probabilities(score_query(model, ["motor", "current"])) == probabilities(answer))
records[0][1].pop()
checks["training_row_shape_class_count_and_types_rejected"] = all(
rejected(fit_count_model, value) for value in
(None, [], "motor", [("electrical", ["motor"])], [("electrical", ["motor"], "extra")],
[(True, ["motor"]), ("software", ["code"])],
[(label, ["motor"]) for label in ("a", "b", "c", "d", "e")]))
checks["token_alphabet_size_and_document_limits_rejected"] = all(
rejected(fit_count_model, value) for value in
([("electrical", []), ("software", ["code"])],
[("electrical", ["Motor"]), ("software", ["code"])],
[("electrical", ["two words"]), ("software", ["code"])],
[("electrical", ["a" * 25]), ("software", ["code"])],
[("electrical", ["motor"] * 17), ("software", ["code"])],
records * 5,
[("a" if index % 2 == 0 else "b",
["t" + chr(97 + number // 26) + chr(97 + number % 26)
for number in range(index * 16, (index + 1) * 16)]) for index in range(5)]))
checks["query_shape_types_and_length_rejected"] = all(
rejected(score_query, model, query) for query in
(None, "motor", [1], [True], [""], ["Motor"], ["motor"] * 129))
long_result = score_query(model, ["motor"] * 128)
checks["bounded_long_query_has_finite_log_scores_and_normalized_mass"] = (
all(math.isfinite(row["log_joint"]) and 0 <= row["probability"] <= 1
for row in long_result["ranking"])
and close(math.fsum(probabilities(long_result).values()), 1))
reordered = fit_count_model(list(reversed(records)))
checks["training_order_invariance"] = (
reordered == model and score_query(reordered, ["motor", "current"]) == answer)
cases = 0
oracle_ok = True
for length in range(4):
for query in product(model.vocabulary, repeat=length):
expected = exact_reference(records, query)
actual = probabilities(score_query(model, query))
oracle_ok &= all(close(actual[label], value) for label, value in expected.items())
cases += 1
checks["independent_exact_product_oracle_259_queries_one_group"] = bool(oracle_ok and cases == 259)
assert all(type(value) is bool and value for value in checks.values()), checks
return dict(python=platform.python_version(), dependencies={"standard_library_only": True,
"scikit_learn_executed": False}, fixture="four original synthetic engineering queries",
classes=list(model.classes), vocabulary=list(model.vocabulary),
document_counts=list(model.document_counts), token_counts=[list(row) for row in model.token_counts],
query=["motor", "current"], result=answer,
long_query_result={"known_count": len(long_result["known_tokens"]),
"ranking": long_result["ranking"]},
hand_joint_masses={"electrical": "3/100", "software": "1/200"},
hand_posterior={"electrical": "6/7", "software": "1/7"},
independent_oracle_cases=cases, checks=checks,
limitations=["Model probabilities on four invented examples are not calibrated confidence, truth or routing permission.",
"Exact lowercase ASCII token lists only; no ASR, tokenizer, text normalization or actual calls.",
"Caller uses fit_count_model output; no untrusted serialized model validation."])
if __name__ == "__main__":
print(json.dumps(run_example(), ensure_ascii=False, indent=2))
یک تا شانزده سند، هرکدام یک تا شانزده توکن، دو تا چهار کلاس و حداکثر شصتوچهار عضو واژگان پذیرفته میشود. رشتههای کلاس و توکن باید یک تا بیستوچهار حرف کوچک لاتین باشند. پرسش صفر تا ۱۲۸ توکن میگیرد. این مرزها قراردادیاند؛ جداسازی جمله، فارسیسازی حروف، حذف نشانه و تبدیل نوعِ خودکار نداریم.
مرجع مستقل، دوباره سندهای هر کلاس را پیدا میکند، وقوعها را با شمارش مستقیم میگیرد و احتمالها را با کسرهای دقیق ضرب میکند؛ مدلِ آماده یا امتیاز لگاریتمیِ مسیر اصلی را مصرف نمیکند. تمام ۲۵۹ پرسش با طول صفر تا سه روی شش واژه مقایسه میشوند. این مجموعه فقط یک گروه مرجع در میان شانزده گروه اجرایی است.
{
"python": "3.14.5",
"dependencies": {
"standard_library_only": true,
"scikit_learn_executed": false
},
"fixture": "four original synthetic engineering queries",
"classes": [
"electrical",
"software"
],
"vocabulary": [
"code",
"current",
"error",
"motor",
"python",
"voltage"
],
"document_counts": [
2,
2
],
"token_counts": [
[
0,
1,
0,
2,
0,
1
],
[
1,
0,
1,
0,
2,
0
]
],
"query": [
"motor",
"current"
],
"result": {
"selected_label": "electrical",
"reason": "model_score",
"known_tokens": [
"motor",
"current"
],
"unknown_tokens": [],
"ranking": [
{
"label": "electrical",
"log_joint": -3.506557897319982,
"probability": 0.8571428571428571
},
{
"label": "software",
"log_joint": -5.298317366548036,
"probability": 0.1428571428571429
}
]
},
"long_query_result": {
"known_count": 128,
"ranking": [
{
"label": "electrical",
"log_joint": -154.80166613427977,
"probability": 1.0
},
{
"label": "software",
"log_joint": -295.42403908379777,
"probability": 8.481631423210935e-62
}
]
},
"hand_joint_masses": {
"electrical": "3/100",
"software": "1/200"
},
"hand_posterior": {
"electrical": "6/7",
"software": "1/7"
},
"independent_oracle_cases": 259,
"checks": {
"literal_class_priors_vocabulary_and_counts": true,
"hand_joint_masses_normalize_to_six_sevenths": true,
"repeated_query_terms_are_not_deduplicated": true,
"query_order_does_not_change_scores": true,
"exact_fixture_tie_uses_lexicographic_label": true,
"unknown_terms_are_reported_and_do_not_expand_vocabulary": true,
"empty_and_all_unknown_queries_abstain_but_expose_priors": true,
"empirical_prior_counts_documents_not_tokens": true,
"duplicate_training_rows_have_explicit_count_weight": true,
"fitted_tuples_are_detached_from_mutable_training_input": true,
"training_row_shape_class_count_and_types_rejected": true,
"token_alphabet_size_and_document_limits_rejected": true,
"query_shape_types_and_length_rejected": true,
"bounded_long_query_has_finite_log_scores_and_normalized_mass": true,
"training_order_invariance": true,
"independent_exact_product_oracle_259_queries_one_group": true
},
"limitations": [
"Model probabilities on four invented examples are not calibrated confidence, truth or routing permission.",
"Exact lowercase ASCII token lists only; no ASR, tokenizer, text normalization or actual calls.",
"Caller uses fit_count_model output; no untrusted serialized model validation."
]
}
جواب دستی ششهفتم، اثر تکرار، تساوی، ناشناختهها، تغییر ترتیب، سقفها و پیشین نامتوازن جدا بررسی شدهاند. افزودن یک سند برقِ تکراری، پیشین آن را سهپنجم میکند و وقوعهایش را هم افزایش میدهد. کد، سندِ تکراری را خودکار بیاثر نمیکند؛ اینکه آن ردیف باید وزن داشته باشد، مسئلهٔ آمادهسازی داده است.
۵. احتمال مدل را اعتماد به پاسخ ننامیم

فرض مدل، استقلال شرطی ویژگیهاست؛ در زبان واقعی، واژهها وابستهاند. راهنمای رسمی نیز دربارهٔ تعبیر بیاحتیاط احتمالهای Naive Bayes هشدار میدهد. چهار نمونهٔ ساختگی، برآورد اعتبارِ آماری، صحت حوزهبندی یا کالیبراسیون نیستند. احتمال نزدیک یک، حتی اگر حسابش درست باشد، اجازهٔ نادیدهگرفتن شاهد یا آغاز تماس ایجاد نمیکند.
ارزیابی احتمال با Brier و بازههای فراوانی مسئولیت متفاوتی دارد و جای دادهٔ مستقل را نمیگیرد. این درس آن ارزیابی را روی نمونهٔ آموزش انجام نداده است. منابع در ۲۵ مهر ۱۴۰۵ خوانده شدند؛ کتاب متعلق به ۲۰۰۸، تاریخ تولید صفحهٔ آن ۲۰۰۹ و نسخهٔ مستندات کتابخانه 1.9.1 است. این آموزش، خبر یا نتیجهٔ محصول نیست.
پرسش اول: چرا مخرج شرطی ده است؟ پاسخ: چهار وقوع واقعی داریم و برای هر شش واژه یک شمارِ هموارسازی افزوده میشود؛ تعداد سندهای کلاس در این مخرج نمیآید.
پرسش دوم: آیا لیزر باعث گسترش مدل میشود؟ پاسخ: خیر؛ جدا گزارش و از امتیاز کنار گذاشته میشود. پرسش تماماً ناشناخته برچسب انتخابی ندارد، حتی اگر پیشینها برابر نباشند.
پرسش سوم: چرا تکرار موتور نتیجه را تغییر داد؟ پاسخ: هر وقوع یک عامل شرطی دارد؛ برای دو موتور، نسبت جرم برق به نرمافزار نه میشود و احتمال برق نهدهم است.
پرسش چهارم: ششهفتم، احتمال پاسخ صحیح است؟ پاسخ: خیر؛ فقط احتمال کلاس تحت مدلِ شمارشیِ تعریفشده است. صحت پاسخ، ارتباط سند و سیاست ارجاع، داده و بررسی مستقل میخواهند.
منابع و مطالعهٔ بیشتر
- Stanford؛ یادگیری شمارشها، پیشین کلاس و هموارسازی Naive Bayes
- scikit-learn؛ مدل چندجملهای و محدودیت تعبیر احتمال خروجی
بررسی منابع: ۲۵ مهر ۱۴۰۵. تحلیلها و پیشنهادهای اجرایی، جمعبندی نویسندهاند.

