شیدهوش
فصل ۳ · درس ۱۱ از ۱۲

۱۱. پروژه (۱): خط‌کش‌های ذهن‌یار

تا اینجا فقط «حس» می‌کردیم ذهن‌یار خوب جواب می‌دهد یا نه. در این درس برایش خط‌کش می‌سازیم: سرگشتگی، شش سنجهٔ شباهت، یک امبدینگ از صفر، و یک دادهٔ مرجع کوچک — و برای اولین بار ذهن‌یار را با عدد می‌سنجیم.

زمان مطالعه: ۷۲ دقیقهمرجع در کتاب: پروژهٔ طراحی‌شده برای این دوره، بر پایهٔ درس‌های ۲ تا ۶ همین فصل

چه می‌سازیم؟

evals/qa.jsonسؤال + پاسخ مرجعذهن‌یارپاسخ تولیدشدهmetrics.py + embedding.pyسنجه‌های دقیق (درس ۷)judge.pyداور خودکار (درس ۸)پاسخ مرجع از همان فایل به هر دو می‌رسد
مسیر داده در پروژهٔ فصل ۳. سؤال‌ها و پاسخ‌های مرجع از یک فایل می‌آیند؛ ذهن‌یار جواب می‌دهد؛ و جوابش با خط‌کش‌های این درس و داور درس بعد سنجیده می‌شود.
فایلتازه یا تغییر؟کارش
corpus/lessons.txtتغییرمتن درس‌ها، حالا با فصل ۳
zehnyar/metrics.pyتازهسرگشتگی و شش سنجهٔ شباهت
zehnyar/embedding.pyتازهتبدیل متن به بردار، از صفر
evals/qa.jsonتازهدادهٔ مرجع: سؤال + پاسخ‌های درست
projects/ch03_evaluation/run.pyتازهاسکریپت اجرایی این درس

قدم ۱ — پوشه‌های تازه

یک پوشه برای دادهٔ مرجع (evals) و یک پوشه برای اسکریپت‌های این فصل لازم داریم:

PowerShell
New-Item -ItemType Directory -Force evals, projects\ch03_evaluation
New-Item -ItemType File projects\ch03_evaluation\__init__.py

قدم ۲ — متن تازهٔ درس‌ها

فصل ۳ متن تازه‌ای به دوره اضافه کرده. نسخهٔ تازه را دانلود کنید: دانلود متن درس‌ها تا فصل ۳ (lessons.txt). بعد آن را در پوشهٔ corpus بگذارید و وقتی پرسید، جایگزین فایل قبلی کنید.

قدم ۳ — خط‌کش‌ها: metrics.py

همهٔ سنجه‌های درس‌های ۲ تا ۶ در یک فایل. طولانی است ولی هر بخشش ساده است؛ جدول‌های بعد از کد، بخش‌به‌بخش توضیحش می‌دهند. در پوشهٔ zehnyar بسازیدش:

zehnyar/metrics.py
"""خط‌کش‌های ذهن‌یار — سنجه‌های فصل ۳.

دو دسته سنجه این‌جاست:
  ۱. سنجهٔ مدل زبانی: سرگشتگی (perplexity) — مدل چقدر از یک متن غافلگیر می‌شود
  ۲. سنجه‌های شباهت با پاسخ مرجع: تطابق دقیق، هم‌پوشانی واژه‌ها،
     فاصلهٔ ویرایشی، هم‌پوشانی n-gram و شباهت کسینوسی

همه بدون هیچ کتابخانهٔ بیرونی.
"""
import math
import re


# ---------------------------------------------------------------------------
#  ۱. سرگشتگی
# ---------------------------------------------------------------------------

def perplexity(lm, text: str) -> dict:
    """سرگشتگی مدل روی یک متن، به‌همراه غافلگیری تک‌تک توکن‌ها.

    غافلگیری هر توکن = -log2(احتمالی که مدل به آن داده بود)  [بیت]
    آنتروپی متقاطع     = میانگین غافلگیری‌ها
    سرگشتگی            = 2 به توان آنتروپی متقاطع
    """
    from .tokenizer import EOS

    ids = lm.tok.encode(text) + [EOS]          # پایان متن را هم باید حدس بزند
    context, tokens, total_bits = [], [], 0.0
    for t in ids:
        p = lm.probs(context)[t]
        bits = -math.log2(p)
        total_bits += bits
        tokens.append((lm._show(t), p, bits))
        context.append(t)

    cross_entropy = total_bits / len(ids)
    return {
        "tokens": tokens,
        "cross_entropy": cross_entropy,                       # بیت به‌ازای هر توکن
        "perplexity": 2 ** cross_entropy,
        "bpc": total_bits / max(len(text), 1),                # بیت به‌ازای هر نویسه
        "bpb": total_bits / max(len(text.encode("utf-8")), 1),  # بیت به‌ازای هر بایت
    }


# ---------------------------------------------------------------------------
#  ۲. یکسان‌سازی متن فارسی
# ---------------------------------------------------------------------------

# «ي» و «ك» عربی با «ی» و «ک» فارسی ظاهراً یکی‌اند ولی برای کامپیوتر دو حرف جدا
_ARABIC = str.maketrans({"ي": "ی", "ك": "ک", "ة": "ه", "ۀ": "هٔ", "‌": " "})
_DIGITS = str.maketrans("۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩", "01234567890123456789")


def normalize(text: str) -> str:
    """متن را یکسان می‌کند تا تفاوت‌های ظاهری، «نه» حساب نشوند."""
    t = text.translate(_ARABIC).translate(_DIGITS).lower()
    t = re.sub(r"[^\w\s]", " ", t)               # علامت‌های نگارشی بیرون
    return re.sub(r"\s+", " ", t).strip()


def words(text: str) -> list[str]:
    return normalize(text).split()


# ---------------------------------------------------------------------------
#  ۳. تطابق دقیق
# ---------------------------------------------------------------------------

def exact_match(prediction: str, references: list[str]) -> bool:
    """دقیقاً برابر یکی از پاسخ‌های مرجع است؟ (بعد از یکسان‌سازی)"""
    p = normalize(prediction)
    return any(p == normalize(r) for r in references)


def contains_match(prediction: str, references: list[str]) -> bool:
    """یکی از پاسخ‌های مرجع **داخل** پاسخ هست؟ — آسان‌گیرتر، ولی خطرناک‌تر."""
    p = normalize(prediction)
    return any(normalize(r) in p for r in references)


# ---------------------------------------------------------------------------
#  ۴. شباهت واژگانی
# ---------------------------------------------------------------------------

def word_overlap(prediction: str, reference: str) -> float:
    """چه کسری از واژه‌های مرجع در پاسخ آمده؟ (بین ۰ و ۱)"""
    ref = words(reference)
    pred = set(words(prediction))
    return sum(1 for w in ref if w in pred) / len(ref) if ref else 0.0


def edit_distance(a: str, b: str) -> int:
    """کمترین تعداد حذف، درج یا جایگزینی حرف برای رسیدن از a به b."""
    prev = list(range(len(b) + 1))
    for i, ca in enumerate(a, 1):
        cur = [i]
        for j, cb in enumerate(b, 1):
            cur.append(min(prev[j] + 1,                 # حذف
                           cur[j - 1] + 1,              # درج
                           prev[j - 1] + (ca != cb)))   # جایگزینی (اگر فرق داشتند)
        prev = cur
    return prev[-1]


def edit_similarity(a: str, b: str) -> float:
    """فاصلهٔ ویرایشی، تبدیل‌شده به شباهتی بین ۰ و ۱."""
    a, b = normalize(a), normalize(b)
    longest = max(len(a), len(b))
    return 1 - edit_distance(a, b) / longest if longest else 1.0


def ngram_overlap(prediction: str, reference: str, n: int = 2) -> float:
    """چه کسری از n-gramهای مرجع در پاسخ هم هست؟ (خانوادهٔ ROUGE)"""
    def grams(ws):
        return [tuple(ws[i:i + n]) for i in range(len(ws) - n + 1)]
    ref = grams(words(reference))
    pred = set(grams(words(prediction)))
    return sum(1 for g in ref if g in pred) / len(ref) if ref else 0.0


# ---------------------------------------------------------------------------
#  ۵. شباهت کسینوسی
# ---------------------------------------------------------------------------

def cosine(a: list[float], b: list[float]) -> float:
    """کسینوس زاویهٔ بین دو بردار: ۱ یعنی هم‌جهت، ۰ یعنی بی‌ربط، -۱ یعنی مخالف."""
    dot = sum(x * y for x, y in zip(a, b))
    na = math.sqrt(sum(x * x for x in a))
    nb = math.sqrt(sum(y * y for y in b))
    return dot / (na * nb) if na and nb else 0.0


def all_scores(prediction: str, references: list[str], embedder=None) -> dict:
    """همهٔ سنجه‌های شباهت یک پاسخ، در برابر بهترین پاسخ مرجع."""
    best = lambda f: max(f(prediction, r) for r in references)
    scores = {
        "exact": exact_match(prediction, references),
        "contains": contains_match(prediction, references),
        "word_overlap": best(word_overlap),
        "edit_sim": best(edit_similarity),
        "bigram": best(ngram_overlap),
    }
    if embedder is not None:
        scores["semantic"] = best(lambda p, r: cosine(embedder.embed(p), embedder.embed(r)))
    return scores

بخش ۱: سرگشتگی

این تکه از کدیعنی چه
def perplexity(lm, text)ورودی: مدل زبانی فصل ۲ و یک متن. خروجی: یک دیکشنری با چند عدد
from .tokenizer import EOS داخل تابعتوکن «پایان متن» را بیاور. مدل باید پایان جمله را هم حدس بزند، پس آن را هم حساب می‌کنیم
lm.probs(context)[t]احتمالی که مدل، با دیدن توکن‌های قبلی، به توکن واقعیِ بعدی داده بود
bits = -math.log2(p)غافلگیری این توکن به بیت — همان فرمول درس ۳
context.append(t)توکن واقعی را به زمینه اضافه کن و برو سراغ بعدی
cross_entropy = total_bits / len(ids)میانگین غافلگیری = آنتروپی متقاطع
2 ** cross_entropyسرگشتگی
len(text.encode("utf-8"))تعداد بایت متن. هر حرف فارسی در UTF-8 دو بایت است، برای همین BPB از BPC کمتر می‌شود

بخش ۲ و ۳: یکسان‌سازی و تطابق دقیق

این تکه از کدیعنی چه
str.maketrans({...})یک «جدول جایگزینی» حرف‌به‌حرف بساز: «ي» عربی ← «ی» فارسی، «ك» ← «ک»، نیم‌فاصله ← فاصله
text.translate(_ARABIC)جدول را روی متن اعمال کن. دو جدول داریم: یکی برای حروف عربی، یکی برای رقم‌های فارسی و عربی ← انگلیسی
re.sub(r"[^\w\s]", " ", t)هر چیزی که نه حرف/رقم است (\w) و نه فاصله (\s) را با فاصله عوض کن — یعنی علامت‌های نگارشی بیرون. علامت ^ داخل کروشه یعنی «هرچه جز این‌ها»
def words(text)متن یکسان‌شده را با فاصله به واژه‌ها بشکن
any(p == normalize(r) for r in references)«آیا هیچ کدام از پاسخ‌های مرجع برابر پاسخ است؟» any اگر حتی یکی درست باشد، True می‌دهد
normalize(r) in pدر contains_match: «آیا متن مرجع داخل پاسخ هست؟»

بخش ۴: شباهت واژگانی

این تکه از کدیعنی چه
pred = set(words(prediction))واژه‌های پاسخ را در یک مجموعه بریز. مجموعه برای پرسیدن «این واژه هست؟» خیلی سریع است
sum(1 for w in ref if w in pred) / len(ref)بشمار چند واژهٔ مرجع در پاسخ هست، تقسیم بر تعداد واژه‌های مرجع
def edit_distance(a, b)فاصلهٔ ویرایشی با روشی به اسم برنامه‌نویسی پویا: جدولی می‌سازد که خانهٔ (i, j) آن می‌گوید «از i حرف اول a تا j حرف اول b چند ویرایش است». برای صرفه‌جویی، فقط ردیف قبلی (prev) و ردیف فعلی (cur) را نگه می‌دارد
min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (ca != cb))ارزان‌ترین از سه راه: حذف یک حرف، درج یک حرف، یا جایگزینی (که اگر دو حرف یکی باشند هزینه‌اش صفر است — (ca != cb) در پایتون یعنی ۰ یا ۱)
1 - edit_distance(a, b) / longestفاصله را به شباهت بین ۰ و ۱ تبدیل کن: صفر ویرایش یعنی ۱
tuple(ws[i:i + n])در ngram_overlap: تکهٔ n واژه‌ای از جای i. ws[i:i+n] یعنی «از عضو i تا قبل از i+n»؛ tuple آن را به شکلی درمی‌آورد که بشود در مجموعه گذاشت

بخش ۵: کسینوس و همه با هم

این تکه از کدیعنی چه
def cosine(a, b)همان فرمول درس ۶. اگر طول یکی از بردارها صفر باشد (متنی که هیچ واژهٔ آشنایی ندارد)، به‌جای خطای تقسیم بر صفر، ۰ برمی‌گرداند
best = lambda f: max(f(prediction, r) for r in references)یک تابع کوچک: هر سنجه را با همهٔ پاسخ‌های مرجع حساب کن و بهترین را بردار. اگر یکی از مرجع‌ها شبیه است، کافی است
if embedder is not None:شباهت معنایی فقط وقتی حساب می‌شود که یک امبدینگ داده شده باشد

قدم ۴ — امبدینگ از صفر: embedding.py

حالا امبدینگ، با همان ایدهٔ درس ۶: «معنای هر واژه را از همسایه‌هایش بشناس». سه قدم دارد که در توضیح بالای فایل آمده. در پوشهٔ zehnyar بسازیدش:

zehnyar/embedding.py
"""امبدینگ ذهن‌یار — تبدیل متن به بردار، از صفر و بدون کتابخانهٔ بیرونی.

ایده‌اش جمله‌ای قدیمی از زبان‌شناسی است:
    «معنای هر واژه را از همسایه‌هایش بشناس.»
اگر «دما» و «top-p» در متن درس‌ها مدام کنار واژه‌های مشترکی مثل «احتمال»
و «نمونه‌برداری» بیایند، بردارشان هم شبیه می‌شود — بی‌آنکه کسی به مدل
گفته باشد این دو به هم ربط دارند.

چهار قدم:
  ۱. ریشه‌یابی سبک: «پاسخ‌ها» و «پاسخ» یک واژه حساب شوند
  ۲. هم‌نشینی: بشمار هر واژه در فاصلهٔ ۴ واژه‌ای از کدام واژه‌ها آمده
  ۳. PMI: هم‌نشینی‌هایی که بیشتر از «شانسی» اتفاق افتاده‌اند را پررنگ کن
  ۴. فشرده‌سازی و پرداخت: بردار هزاران‌بعدی را با «تصویر تصادفی» به ۱۲۸
     عدد برسان، طول همه را یکی کن، و جهتِ مشترکِ همه را از آن‌ها کم کن

این مدل کوچک روی چند صد پاراگراف آموزش می‌بیند؛ مدل‌های امبدینگ واقعی
روی میلیاردها جمله. پس انتظار معجزه نداشته باشید — ولی ایده همان است.
"""
import math
import random
import re
from collections import Counter, defaultdict

from .corpus import load_corpus

DIM = 128          # طول هر بردار
WINDOW = 4         # چند واژه این‌طرف و آن‌طرف را همسایه حساب کنیم
MIN_COUNT = 2      # واژه‌هایی که فقط یک بار آمده‌اند چیزی یاد نمی‌دهند

# واژه‌های پرتکراری که معنای خاصی نمی‌رسانند و همسایهٔ همه‌چیزند
STOPWORDS = set("""و در به از که این را با است برای یک آن هم تا اگر یا ها بر
نه همین هر ولی می‌شود می‌کند شود کند باید چه دیگر خود""".split())

# تکه‌ها و پسوندهای دستوری فارسی که معنای اصلی واژه را عوض نمی‌کنند
PIECES = set("ها های تر ترین ی ای می نمی".split())
SUFFIXES = ("ترین", "های", "ها", "تر", "ای", "ی")

_WORD = re.compile(r"[\w‌]+")


def stem(word: str) -> str:
    """ریشه‌یابی خیلی ساده: «می‌شوند» ← «شوند»، «پاسخ‌ها» ← «پاسخ»، «زبانی» ← «زبان»."""
    parts = [p for p in word.split("‌") if p and p not in PIECES]
    w = "".join(parts)
    for s in SUFFIXES:
        if w.endswith(s) and len(w) - len(s) >= 3:     # ریشهٔ خیلی کوتاه نماند
            return w[:-len(s)]
    return w


def tokenize_words(text: str) -> list[tuple[str, str]]:
    """فهرست (ریشه، شکل اصلی) واژه‌های معنادار متن."""
    out = []
    for w in _WORD.findall(text.lower()):
        if w in STOPWORDS or w.isdigit():
            continue
        s = stem(w)
        if s and s not in STOPWORDS:
            out.append((s, w))
    return out


class WordEmbedding:
    def __init__(self):
        self.vectors: dict[str, list[float]] = {}
        self.idf: dict[str, float] = {}
        self.surface: dict[str, str] = {}     # ریشه ← رایج‌ترین شکل نوشتاری‌اش

    def train(self, texts: list[str], seed: int = 0) -> "WordEmbedding":
        pairs = [tokenize_words(t) for t in texts]
        docs = [[s for s, _ in p] for p in pairs]
        counts = Counter(w for d in docs for w in d)
        vocab = {w for w, c in counts.items() if c >= MIN_COUNT}

        # برای نمایش: هر ریشه با رایج‌ترین شکلی که در متن داشته
        forms = defaultdict(Counter)
        for p in pairs:
            for s, w in p:
                forms[s][w] += 1
        self.surface = {s: f.most_common(1)[0][0] for s, f in forms.items()}

        # ۲. هم‌نشینی
        co = defaultdict(Counter)
        for d in docs:
            ws = [w for w in d if w in vocab]
            for i, w in enumerate(ws):
                for j in range(max(0, i - WINDOW), min(len(ws), i + WINDOW + 1)):
                    if i != j:
                        co[w][ws[j]] += 1
        total = sum(sum(c.values()) for c in co.values())
        row = {w: sum(c.values()) for w, c in co.items()}

        # ۴. برای هر واژه یک بردار تصادفی ثابت از +۱ و -۱
        rng = random.Random(seed)
        basis = {w: [rng.choice((-1.0, 1.0)) for _ in range(DIM)] for w in sorted(vocab)}

        # ۳ و ۴. بردار هر واژه = جمع بردار همسایه‌ها، هر کدام با وزن PMI
        for w, neighbours in co.items():
            v = [0.0] * DIM
            for u, n in neighbours.items():
                pmi = math.log(n * total / (row[w] * row[u]))
                if pmi > 0:                        # فقط هم‌نشینی‌های «معنادار»
                    b = basis[u]
                    for k in range(DIM):
                        v[k] += pmi * b[k]
            length = math.sqrt(sum(x * x for x in v)) or 1.0
            self.vectors[w] = [x / length for x in v]          # طول همه یکی

        # ۴. جهت مشترک: میانگین همهٔ بردارها را از هر کدام کم کن
        mean = [sum(v[k] for v in self.vectors.values()) / len(self.vectors) for k in range(DIM)]
        self.vectors = {w: [x - m for x, m in zip(v, mean)] for w, v in self.vectors.items()}

        # واژهٔ کمیاب‌تر، در معنای جمله سهم بیشتری دارد (وزن IDF)
        doc_freq = Counter(w for d in docs for w in set(d))
        self.idf = {w: math.log(len(docs) / doc_freq[w]) for w in vocab}
        return self

    def embed(self, text: str) -> list[float]:
        """امبدینگ یک جمله = جمع وزن‌دار بردار واژه‌هایش."""
        v = [0.0] * DIM
        for s, _ in tokenize_words(text):
            if s in self.vectors:
                weight = self.idf.get(s, 1.0)
                for k, x in enumerate(self.vectors[s]):
                    v[k] += weight * x
        return v

    def nearest(self, word: str, k: int = 6) -> list[tuple[str, float]]:
        """نزدیک‌ترین واژه‌ها به یک واژه — برای دیدن اینکه مدل چه یاد گرفته."""
        from .metrics import cosine
        key = stem(word.lower())
        if key not in self.vectors:
            return []
        target = self.vectors[key]
        scored = [(self.surface.get(u, u), cosine(target, v))
                  for u, v in self.vectors.items() if u != key]
        return sorted(scored, key=lambda x: -x[1])[:k]


_model: WordEmbedding | None = None


def get_embedder() -> WordEmbedding:
    """امبدینگ را یک بار روی پیکره آموزش می‌دهد و نگه می‌دارد (حدود یک ثانیه)."""
    global _model
    if _model is None:
        _model = WordEmbedding().train(load_corpus())
    return _model
این تکه از کدیعنی چه
DIM = 128، WINDOW = 4، MIN_COUNT = 2سه تنظیم: طول بردار، اندازهٔ همسایگی، و حداقل تکرار یک واژه. این‌ها ابرپارامترند (فصل ۲، درس ۵)
STOPWORDSواژه‌های پرتکراری مثل «و» و «در» که کنار همه‌چیز می‌آیند و دربارهٔ معنا چیزی نمی‌گویند. .split() رشتهٔ چندخطی را به واژه‌ها می‌شکند
def stem(word)ریشه‌یابی سبک: واژه را در نیم‌فاصله‌ها می‌شکند، تکه‌های دستوری مثل «ها» و «می» را دور می‌ریزد، و یکی از پسوندهای «ترین، های، ها، تر، ای، ی» را از آخرش برمی‌دارد — به شرطی که دست‌کم سه حرف بماند. بدون این، «پاسخ»، «پاسخ‌ها» و «پاسخی» سه واژهٔ بی‌ربط حساب می‌شدند
tokenize_words و (s, w)برای هر واژه هم ریشه را نگه می‌داریم (برای حساب) هم شکل اصلی را (برای نمایش)
self.surfaceبرای هر ریشه، رایج‌ترین شکلی که در متن آمده — تا در خروجی به‌جای «زبان» ناقص، واژهٔ واقعی را ببینید. most_common(1)[0][0] یعنی «پرتکرارترین عضو»
co = defaultdict(Counter)جدول هم‌نشینی: برای هر واژه، شمارش اینکه کنار چه واژه‌هایی آمده
range(max(0, i - WINDOW), min(len(ws), i + WINDOW + 1))از ۴ واژه قبل تا ۴ واژه بعد — بدون اینکه از اول یا آخر متن بیرون بزند
pmi = math.log(n * total / (row[w] * row[u]))PMI: این دو واژه چند برابر بیشتر از «شانسی» کنار هم آمده‌اند؟ اگر هر دو خیلی رایج باشند، کنار هم آمدنشان چیز خاصی نیست؛ PMI این را جبران می‌کند. فقط مقدارهای مثبت حساب می‌شوند
basis = {w: [rng.choice((-1.0, 1.0)) ...]}به هر واژه یک بردار تصادفی ثابت ۱۲۸ تایی از ۱+ و ۱- بده. این ترفند تصویر تصادفی است: به‌جای برداری به طول هزاران (یکی برای هر واژه)، فقط ۱۲۸ عدد نگه می‌داریم و فاصله‌ها تقریباً حفظ می‌شوند. بذر ثابت یعنی هر بار همان بردارها
v[k] += pmi * b[k]بردار هر واژه = جمع بردار تصادفی همسایه‌هایش، هر کدام با وزن PMI. دو واژه با همسایه‌های مشترک، بردار شبیه می‌گیرند
[x / length for x in v]طول همهٔ بردارها را ۱ کن تا واژه‌های پرتکرار فقط به‌خاطر بزرگی بردارشان بر معنای جمله مسلط نشوند
mean = ... و x - mکم کردن جهت مشترک: همهٔ بردارها کمی به یک سمت خم شده‌اند (همان چیزهایی که در همهٔ متن‌ها هست). با کم کردن میانگین، این جهت مشترک حذف می‌شود و تفاوت‌های واقعی دیده می‌شوند. بدون این، شباهت دو جملهٔ بی‌ربط هم بالا درمی‌آمد
self.idf = {...}وزن IDF: واژه‌ای که در پاراگراف‌های کمتری آمده، اطلاعات بیشتری دارد و در معنای جمله سهم بیشتری می‌گیرد
def embed(self, text)امبدینگ جمله = جمع وزن‌دار بردار واژه‌هایش. واژهٔ ناشناخته نادیده گرفته می‌شود
def nearest(self, word, k)نزدیک‌ترین واژه‌ها با کسینوس — پنجره‌ای به اینکه مدل چه یاد گرفته

قدم ۵ — دادهٔ مرجع: evals/qa.json

حالا چند سؤال با پاسخ درستشان. قالب فایل JSONJavaScript Object Notationنشانه‌گذاری شیء جاوااسکریپت است: یک فهرست (کروشه) از چند شیء (آکولاد)، که هر کدام یک question و یک فهرست references دارد. در پوشهٔ evals بسازیدش:

evals/qa.json
[
  {
    "question": "مدل پایه چیست؟",
    "references": [
      "مدل پایه مدلی بزرگ و همه‌کاره است که می‌شود روی آن اپلیکیشن‌های مختلفی ساخت."
    ]
  },
  {
    "question": "توکن چیست؟",
    "references": [
      "توکن تکه‌ای بین حرف و کلمه است که مدل با آن کار می‌کند."
    ]
  },
  {
    "question": "تنظیم دقیق یعنی چه؟",
    "references": [
      "تنظیم دقیق یعنی ادامه دادن آموزش یک مدل آماده روی داده‌های خودمان، که وزن‌های مدل را عوض می‌کند."
    ]
  },
  {
    "question": "پشتهٔ مهندسی هوش مصنوعی چند لایه دارد؟",
    "references": [
      "سه",
      "سه لایه"
    ]
  },
  {
    "question": "پرامپت چیست؟",
    "references": [
      "پرامپت متنی است که به مدل می‌دهیم تا ادامه‌اش را بنویسد یا به آن جواب بدهد."
    ]
  }
]
این تکه از کدیعنی چه
[ ... ]فهرست؛ هر عضو با ویرگول از بعدی جدا می‌شود — ولی بعد از آخرین عضو ویرگول نگذارید (یادتان هست؟ «ویرگول آویزان» فصل ۲)
"references": ["سه", "سه لایه"]یک سؤال می‌تواند چند پاسخ مرجع داشته باشد. سنجه‌ها با بهترینشان حساب می‌شوند
سؤال آخرعمداً سؤالی است که ذهن‌یار (mock) جوابش را بلد نیست — تا ببینیم سنجه‌ها پاسخ بی‌ربط را چطور نمره می‌دهند

قدم ۶ — اجرا کنید

آخرین فایل: اسکریپتی که چهار آزمایش را پشت هم انجام می‌دهد. در پوشهٔ projects/ch03_evaluation بسازیدش:

projects/ch03_evaluation/run.py
"""پروژهٔ فصل ۳ (بخش ۱) — خط‌کش‌های ذهن‌یار.

اجرا:
    .venv\\Scripts\\python.exe -m projects.ch03_evaluation.run
"""
import json
import pathlib

from zehnyar import _console
from zehnyar.core.assistant import Zehnyar
from zehnyar.embedding import get_embedder
from zehnyar.lm import get_lm
from zehnyar.metrics import all_scores, edit_distance, perplexity, word_overlap

_console.setup()   # تا کنسول ویندوز فارسی را درست چاپ کند

EVALS = pathlib.Path(__file__).resolve().parent.parent.parent / "evals" / "qa.json"


def title(text: str) -> None:
    print("\n" + "=" * 56)
    print(" " + text)
    print("=" * 56)


def main() -> None:
    lm = get_lm()

    # ---------- ۱. سرگشتگی ----------
    title("۱. سرگشتگی: مدل چقدر غافلگیر می‌شود؟")
    samples = [
        ("دیده‌شده", "مدل پایه از جعبه که بیرون می‌آید همه‌منظوره است."),
        ("تازه    ", "مادربزرگم هر جمعه آش رشته می‌پزد."),
        ("چرت‌وپرت", "کلمه گربه چون بنفش اینترنت."),
    ]
    for label, text in samples:
        r = perplexity(lm, text)
        print(f"\n  {label} | PPL={r['perplexity']:>7.1f} | "
              f"{r['cross_entropy']:.2f} بیت/توکن | {r['bpb']:.2f} بیت/بایت")
        print(f"           {text}")
        worst = sorted(r["tokens"], key=lambda t: -t[2])[:3]
        print("           غافلگیرکننده‌ترین: " +
              "، ".join(f"{tok} ({bits:.1f} بیت)" for tok, _, bits in worst))

    # ---------- ۲. شباهت واژگانی ----------
    title("۲. شباهت واژگانی")
    ref = "گربه‌های من موش‌ها را می‌ترسانند"
    for cand in ["گربه‌های من موش‌ها را می‌خورند", "گربه‌ها و موش‌ها همیشه دعوا می‌کنند"]:
        print(f"\n  مرجع : {ref}\n  پاسخ : {cand}")
        print(f"  هم‌پوشانی واژه‌ها: {word_overlap(cand, ref):.0%}")
    for a, b in [("bad", "bard"), ("bad", "cash")]:
        print(f"\n  فاصلهٔ ویرایشی {a} ← {b}: {edit_distance(a, b)}")

    # ---------- ۳. امبدینگ ----------
    title("۳. امبدینگ: همسایه‌های معنایی")
    emb = get_embedder()
    for word in ["دما", "سرگشتگی", "شباهت"]:
        near = emb.nearest(word, 5)
        print(f"\n  {word} ← " + "، ".join(f"{w} ({s:.2f})" for w, s in near))

    # ---------- ۴. ارزیابی ذهن‌یار ----------
    bot = Zehnyar()
    title(f"۴. ارزیابی ذهن‌یار با دادهٔ مرجع  |  provider={bot.info()['provider']}")
    items = json.loads(EVALS.read_text(encoding="utf-8"))
    totals = {}
    for item in items:
        answer = bot.ask(item["question"]).text
        s = all_scores(answer, item["references"], emb)
        for k, v in s.items():
            totals[k] = totals.get(k, 0) + float(v)
        print(f"\n  سؤال : {item['question']}")
        print(f"  پاسخ : {answer}")
        print(f"  exact={'✓' if s['exact'] else '✗'}  contains={'✓' if s['contains'] else '✗'}  "
              f"words={s['word_overlap']:.2f}  edit={s['edit_sim']:.2f}  "
              f"bigram={s['bigram']:.2f}  semantic={s['semantic']:.2f}")

    print("\n" + "-" * 56)
    print("میانگین روی " + str(len(items)) + " سؤال:")
    print("  " + "  ".join(f"{k}={v / len(items):.2f}" for k, v in totals.items()))


if __name__ == "__main__":
    main()
این تکه از کدیعنی چه
EVALS = pathlib.Path(__file__).resolve().parent.parent.parent / "evals" / "qa.json"سه پوشه بالا رفتن از projects/ch03_evaluation/run.py می‌رسد به پوشهٔ اصلی پروژه؛ بعد برو داخل evals
samples = [("دیده‌شده", ...), ...]فهرستی از جفت‌ها: برچسب و متن. for label, text in samples هر جفت را باز می‌کند
{r['perplexity']:>7.1f}عدد را با یک رقم اعشار، در هفت خانه و راست‌چین چاپ کن تا ستون‌ها مرتب شوند
sorted(r["tokens"], key=lambda t: -t[2])[:3]توکن‌ها را بر اساس غافلگیری (عضو سوم هر سه‌تایی) از زیاد به کم مرتب کن و سه تای اول را بردار
{word_overlap(cand, ref):.0%}عدد بین ۰ و ۱ را به شکل درصد بی‌اعشار چاپ کن
json.loads(EVALS.read_text(...))فایل JSON را بخوان و به فهرست پایتون تبدیل کن
totals[k] = totals.get(k, 0) + float(v)جمع هر سنجه روی همهٔ سؤال‌ها. float(True) می‌شود ۱ و float(False) می‌شود ۰، پس تطابق دقیق هم میانگین‌پذیر است
'✓' if s['exact'] else '✗'اگر درست بود تیک، وگرنه ضربدر — یک «اگر» یک‌خطی

دستور اجرا، از پوشهٔ اصلی پروژه:

PowerShell
.venv\Scripts\python.exe -m projects.ch03_evaluation.run

بار اول چند ثانیه طول می‌کشد، چون توکنایزر با متن تازه دوباره آموزش می‌بیند. باید خروجی‌ای شبیه این ببینید — این خروجی واقعی همین کد است:

خروجی مورد انتظار

========================================================
 ۱. سرگشتگی: مدل چقدر غافلگیر می‌شود؟
========================================================

  دیده‌شده | PPL=    3.2 | 1.69 بیت/توکن | 0.38 بیت/بایت
           مدل پایه از جعبه که بیرون می‌آید همه‌منظوره است.
           غافلگیرکننده‌ترین: ▁پایه (5.2 بیت)، ▁مدل (4.7 بیت)، ▁می‌آید (3.1 بیت)

  تازه     | PPL=  203.8 | 7.67 بیت/توکن | 2.64 بیت/بایت
           مادربزرگم هر جمعه آش رشته می‌پزد.
           غافلگیرکننده‌ترین: اد (13.5 بیت)، ▁آ (13.5 بیت)، ▁ر (13.5 بیت)

  چرت‌وپرت | PPL=  736.0 | 9.52 بیت/توکن | 2.14 بیت/بایت
           کلمه گربه چون بنفش اینترنت.
           غافلگیرکننده‌ترین: ▁اینترنت (15.6 بیت)، ▁کلمه (14.8 بیت)، ▁گر (13.8 بیت)

========================================================
 ۲. شباهت واژگانی
========================================================

  مرجع : گربه‌های من موش‌ها را می‌ترسانند
  پاسخ : گربه‌های من موش‌ها را می‌خورند
  هم‌پوشانی واژه‌ها: 88%

  مرجع : گربه‌های من موش‌ها را می‌ترسانند
  پاسخ : گربه‌ها و موش‌ها همیشه دعوا می‌کنند
  هم‌پوشانی واژه‌ها: 50%

  فاصلهٔ ویرایشی bad ← bard: 1

  فاصلهٔ ویرایشی bad ← cash: 3

========================================================
 ۳. امبدینگ: همسایه‌های معنایی
========================================================

  دما ← بشناس (0.37)، صفر (0.36)، متوسط (0.36)، توزیع (0.35)، top (0.34)

  سرگشتگی ← کمتر (0.44)، آنتروپی (0.41)، خورد (0.40)، bpc (0.38)، متقاطع (0.34)

  شباهت ← معنایی (0.59)، سنجه (0.36)، امبدینگ (0.35)، معنا (0.32)، واژه (0.31)

========================================================
 ۴. ارزیابی ذهن‌یار با دادهٔ مرجع  |  provider=mock
========================================================

  سؤال : مدل پایه چیست؟
  پاسخ : مدل پایه یک مدل بزرگ و همه‌منظوره است که می‌توان آن را پایهٔ ساخت ده‌ها اپلیکیشن مختلف قرار داد.
  exact=✗  contains=✗  words=0.65  edit=0.56  bigram=0.31  semantic=0.77

  سؤال : توکن چیست؟
  پاسخ : توکن واحد کار مدل است؛ چیزی بین حرف و کلمه. برای انگلیسی هر توکن حدوداً سه‌چهارم یک کلمه است.
  exact=✗  contains=✗  words=0.53  edit=0.43  bigram=0.29  semantic=0.83

  سؤال : تنظیم دقیق یعنی چه؟
  پاسخ : تنظیم دقیق یعنی ادامهٔ آموزش یک مدل آماده روی دادهٔ خودتان، که وزن‌های مدل را تغییر می‌دهد.
  exact=✗  contains=✗  words=0.81  edit=0.82  bigram=0.60  semantic=0.88

  سؤال : پشتهٔ مهندسی هوش مصنوعی چند لایه دارد؟
  پاسخ : پشتهٔ مهندسی هوش مصنوعی سه لایه دارد: توسعهٔ اپلیکیشن، توسعهٔ مدل، و زیرساخت.
  exact=✗  contains=✓  words=1.00  edit=0.10  bigram=1.00  semantic=0.44

  سؤال : پرامپت چیست؟
  پاسخ : هنوز چیزی در این باره بلد نیستم. در فصل ۶ با RAG یاد می‌گیرم که جواب را از متن درس‌ها پیدا کنم.
  exact=✗  contains=✗  words=0.22  edit=0.28  bigram=0.00  semantic=0.20

--------------------------------------------------------
میانگین روی 5 سؤال:
  exact=0.00  contains=0.20  word_overlap=0.64  edit_sim=0.44  bigram=0.44  semantic=0.62

همین سنجه‌ها را در آزمایشگاه درس‌های ۵ و ۶ هم دیدید؛ آن‌ها منطق همین کد را در مرورگر خودتان اجرا می‌کنند.

چه چیزی ساختیم؟

تمرین شما

  1. یک سؤال تازه به evals/qa.json اضافه کنید — مثلاً «خودنظارتی یعنی چه؟» با یک پاسخ مرجع از درس‌ها. حواستان به ویرگول‌ها باشد. دوباره اجرا کنید و نمره‌هایش را ببینید.
  2. به سؤال اول یک پاسخ مرجع دوم اضافه کنید که عیناً همان پاسخ ذهن‌یار باشد. کدام سنجه‌ها ۱ می‌شوند؟ (این یک تقلب است — ولی نشان می‌دهد چرا کیفیت دادهٔ مرجع این‌قدر مهم است.)
  3. در zehnyar/embedding.py مقدار WINDOW را از ۴ به ۲ و بعد به ۸ تغییر دهید و همسایه‌های «دما» را مقایسه کنید. بعد به ۴ برگردانید.

خودآزمایی

چرا پیش از مقایسهٔ دو متن فارسی آن‌ها را یکسان‌سازی می‌کنیم؟
در امبدینگ ذهن‌یار، چرا «دما» و «top-p» بردارهای شبیهی می‌گیرند؟

مرور فلش‌کارت‌های این درس

تعریف

در پروژهٔ ذهن‌یار، normalize چه کارهایی می‌کند؟

برای دیدن پاسخ کلیک کنید یا Space بزنید

کارت ۱ از ۱

آکادمی هوش مصنوعی شیدهوش — بخشی از shidhoosh.com