فصل ۳ · درس ۱۶ از ۴۳

پروژه: داور خودکار با معیار

ذهن‌یار حالا جواب می‌دهد، ولی از کجا بفهمیم خوب جواب می‌دهد؟ در این پروژه سه ابزار ارزیابی این فصل را می‌سازید — سرگشتگی، شباهت واژگانی با یکدست‌سازی فارسی، و یک داور با معیار مکتوب — و اولین کارنامهٔ ذهن‌یار را می‌گیرید.

چه می‌سازیم؟

قطعهفایلمفهوم کدام درس
سرگشتگی، یکدست‌سازی فارسی، هم‌پوشانی و فاصلهٔ ویرایشzehnyar/metrics.pyسنجه‌های مدل زبانی · ارزیابی دقیق
داور با معیار (قاعده‌محور و مدل‌محور) + مهار سوگیری جایگاهzehnyar/judge.pyهوش مصنوعی در نقش داور
هموارسازی احتمال در مدل زبانیzehnyar/lm.py (یک متد تازه)سنجه‌های مدل زبانی

قدم ۱ — هموارسازی: احتمالِ هرگز صفر

برای حساب کردن سرگشتگی باید به هر کلمهٔ جمله احتمالی بدهیم. ولی مدل دوکلمه‌ای ما جفتی را که هرگز ندیده، احتمال صفر می‌دهد؛ و لگاریتم صفر منفی بی‌نهایت است — سرگشتگی بی‌نهایت می‌شود و هیچ چیزی را نمی‌سنجد. راه‌حل کلاسیکش هموارسازی است: به هر جفت ممکن کمی شانس (alpha) بدهیم. این متد را به BigramLM اضافه کنید:

zehnyar/lm.pypython
    def prob(self, prev: str, word: str, alpha: float = 0.1) -> float:
        """احتمال word پس از prev، با هموارسازی (فصل ۳).

        بدون هموارسازی، هر جفتی که در داده ندیده احتمال صفر می‌گیرد و
        سرگشتگی بی‌نهایت می‌شود. alpha به هر جفت ممکن کمی شانس می‌دهد.
        """
        vocab = len(self.counts) + 1
        row = self.counts[prev]
        return (row[word] + alpha) / (sum(row.values()) + alpha * vocab)

قدم ۲ — سنجه‌ها

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

zehnyar/metrics.pypython
"""سنجه‌های فصل ۳: سرگشتگی، یکدست‌سازی فارسی و شباهت واژگانی.

همهٔ این‌ها «ارزیابی دقیق»اند: برای یک ورودی همیشه همان عدد را می‌دهند.
"""
import math
import re
from collections import Counter

from .lm import END, START, BigramLM


def perplexity(lm: BigramLM, sentence: str) -> float:
    """سرگشتگی یک جمله = e به توان منفیِ میانگین لگاریتم احتمال کلمه‌ها."""
    words = [START] + sentence.split() + [END]
    logs = [math.log(lm.prob(a, b)) for a, b in zip(words, words[1:])]
    return math.exp(-sum(logs) / len(logs))


def normalize_fa(text: str) -> str:
    """یکدست‌سازی پیش از هر مقایسهٔ واژگانی فارسی."""
    text = text.translate(str.maketrans("يكۀة٠١٢٣٤٥٦٧٨٩", "یکهه۰۱۲۳۴۵۶۷۸۹"))
    text = re.sub("ه‌ی(?=\\s|$)", "ه", text)      # کسرهٔ اضافه به شکل «ـه‌ی»: «گربه‌ی» ← «گربه»
    text = text.replace("‌", "")                 # نیم‌فاصله: «می‌ترساند» ← «میترساند»
    text = re.sub("[ً-ٰٟ]", "", text)  # اعراب
    text = re.sub(r"(\w)ٔ", r"\1", text)               # کسرهٔ اضافه روی ه: «گربهٔ» ← «گربه»
    text = re.sub(r"[^\w\s]", " ", text)               # نشانه‌گذاری، از جمله ویرگول
    return re.sub(r"\s+", " ", text).strip()


def word_overlap(answer: str, reference: str, normalize: bool = True) -> float:
    """چه نسبتی از کلمه‌های مرجع در پاسخ آمده (unigram recall)."""
    if normalize:
        answer, reference = normalize_fa(answer), normalize_fa(reference)
    ref = reference.split()
    ans = Counter(answer.split())
    hit = 0
    for w in ref:
        if ans[w] > 0:
            hit += 1
            ans[w] -= 1
    return hit / 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, start=1):
        cur = [i]
        for j, cb in enumerate(b, start=1):
            cur.append(min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (ca != cb)))
        prev = cur
    return prev[-1]

قدم ۳ — داور با معیار

دو داور با یک رابط. RubricJudge یک «داور کوچک تخصصی» است — همان جهتی که در درس داوری گفتیم: فقط یک نوع قضاوت، با معیار مشخص. آفلاین کار می‌کند و قطعی است. LLMJudge همان معیارها را در قالب پرامپت به یک مدل واقعی می‌دهد، با دمای صفر — چون داور باید پایدار باشد. هر دو خروجی یکسان می‌دهند، پس بعداً بدون عوض کردن بقیهٔ کد جابه‌جا می‌شوند.

zehnyar/judge.pypython
"""داور با معیار — فصل ۳.

دو داور با یک رابط:

  RubricJudge   داور کوچک تخصصی و قطعی: معیارها را با قاعده می‌سنجد.
                آفلاین کار می‌کند و هر بار همان نمره را می‌دهد.
  LLMJudge      داور مدل‌محور: همان معیارها را در قالب پرامپت به یک مدل
                می‌دهد و خروجی JSON را می‌خواند. با ارائه‌دهندهٔ واقعی
                (ollama یا openai) کار می‌کند.

هر دو خروجی یکسان دارند: {"score": ۱ تا ۵، "reasons": [...]}.
"""
import re
from dataclasses import dataclass, field

from .metrics import normalize_fa, word_overlap
from .providers import Message, Provider
from .structured import repair_json


@dataclass
class Rubric:
    """معیار مکتوب یک پرسش — همان «معیار» پرامپت داور، ولی ساختاریافته."""
    must_mention: list[str] = field(default_factory=list)   # مفهوم‌هایی که پاسخ باید بگوید
    max_words: int = 60                                     # پاسخ بلندتر، نمرهٔ کمتر (جلوی سوگیری پرگویی)
    min_overlap: float = 0.3                                # حداقل هم‌پوشانی با مرجع


def _numbers(text: str) -> set[str]:
    return set(re.findall(r"\d+(?:[.٫]\d+)?", normalize_fa(text)))


class RubricJudge:
    name = "rubric-judge-v1"

    def judge(self, question: str, reference: str, answer: str, rubric: Rubric) -> dict:
        score, reasons = 5, []
        norm = normalize_fa(answer)
        missing = [m for m in rubric.must_mention if normalize_fa(m) not in norm]
        if missing:
            score -= 2
            reasons.append("نگفته: " + "، ".join(missing))
        invented = _numbers(answer) - _numbers(reference)
        if invented:                                        # عددی که در مرجع نیست = توهم احتمالی
            score -= 2
            reasons.append("عدد بی‌پشتوانه: " + "، ".join(sorted(invented)))
        if len(answer.split()) > rubric.max_words:
            score -= 1
            reasons.append(f"بلندتر از {rubric.max_words} کلمه")
        if word_overlap(answer, reference) < rubric.min_overlap:
            score -= 1
            reasons.append("هم‌پوشانی کم با مرجع")
        return {"score": max(1, score), "reasons": reasons or ["همهٔ معیارها برآورده شد"]}


JUDGE_PROMPT = """کار تو: به «پاسخ» از ۱ تا ۵ نمره بده، با توجه به «پاسخ مرجع».
معیارها:
- پاسخ باید این مفهوم‌ها را بگوید: {must}
- هر عددی که در پاسخ هست باید در مرجع هم باشد؛ عدد ساختگی = حداکثر نمرهٔ ۲.
- طول ملاک خوبی نیست؛ پاسخ بیش از {max_words} کلمه یک نمره کم می‌شود.
پرسش: {q}
پاسخ مرجع: {ref}
پاسخ: {ans}
خروجی: فقط JSON به شکل {{"score": عدد، "reasons": ["..."]}}"""


class LLMJudge:
    def __init__(self, provider: Provider) -> None:
        self.provider = provider
        self.name = f"llm-judge/{provider.model}"

    def prompt(self, question: str, reference: str, answer: str, rubric: Rubric) -> str:
        return JUDGE_PROMPT.format(must="، ".join(rubric.must_mention) or "—", max_words=rubric.max_words,
                                   q=question, ref=reference, ans=answer)

    def judge(self, question: str, reference: str, answer: str, rubric: Rubric) -> dict:
        raw = self.provider.complete([Message("user", self.prompt(question, reference, answer, rubric))],
                                     temperature=0).text     # داور باید پایدار باشد: دمای صفر
        data = repair_json(raw) or {}
        score = data.get("score")
        if not isinstance(score, (int, float)) or not 1 <= score <= 5:
            return {"score": None, "reasons": ["داور خروجی معتبر نداد"]}
        return {"score": int(score), "reasons": data.get("reasons", [])}


def compare_both_orders(pick, question: str, a: str, b: str) -> str:
    """مقایسهٔ دو پاسخ، دو بار با ترتیب عوض‌شده — مهار سوگیری جایگاه اول.

    pick(question, first, second) باید "first" یا "second" برگرداند.
    """
    first = "A" if pick(question, a, b) == "first" else "B"
    second = "B" if pick(question, b, a) == "first" else "A"
    return first if first == second else "مساوی (داور با جابه‌جایی نظرش عوض شد)"
  • معیار مکتوب (Rubric) همان بخش «معیار» پرامپت داور است، ولی ساختاریافته: مفهوم‌های لازم، سقف طول (در برابر سوگیری پرگویی) و حداقل هم‌پوشانی با مرجع.
  • عدد بی‌پشتوانه — عددی که در پاسخ هست ولی در مرجع نیست — ساده‌ترین نشانهٔ توهم است. همان حفاظ قیمتی که دستیار همین سایت دارد روی همین ایده کار می‌کند.
  • compare_both_orders هر مقایسه را دو بار با ترتیب عوض‌شده می‌پرسد؛ اگر داور نظرش عوض شد، نتیجه «مساوی» است.

قدم ۴ — اجرا

projects/ch03_judge/run.pypython
"""پروژهٔ فصل ۳ — سنجه‌ها و داور با معیار.

اجرا:
    python -m projects.ch03_judge.run
"""
import random

from zehnyar import _console
from zehnyar.core.assistant import Zehnyar
from zehnyar.corpus import load, sentences
from zehnyar.judge import LLMJudge, Rubric, RubricJudge, compare_both_orders
from zehnyar.lm import BigramLM
from zehnyar.metrics import edit_distance, perplexity, word_overlap
from zehnyar.providers.mock import MockProvider

_console.setup()


def part1_perplexity() -> None:
    print("\n═══ ۱. سرگشتگی: مدل این متن را دیده؟ ═══")
    corpus = sentences(load("fa"))
    lm = BigramLM(corpus)
    seen = corpus[40]
    words = seen.split()
    random.Random(7).shuffle(words)
    rows = [
        ("جملهٔ خود دادهٔ آموزشی", seen),
        ("جملهٔ تازه ولی معنادار", "یک مدل کوچک هم می‌تواند متن فارسی را پیش‌بینی کند"),
        ("همان کلمه‌ها، درهم", " ".join(words)),
    ]
    for label, text in rows:
        print(f"  {perplexity(lm, text):>8.1f}   {label}")
        print(f"            «{text[:60]}»")


def part2_similarity() -> None:
    print("\n═══ ۲. شباهت واژگانی و یکدست‌سازی ═══")
    ref = "گربهٔ من موش را می‌ترساند"
    for ans in ("گربه‌ی من موش را مي‌ترساند", "گربهٔ من موش را می‌خورد"):
        raw = word_overlap(ans, ref, normalize=False)
        norm = word_overlap(ans, ref)
        print(f"  خام {raw:.0%}  ←  یکدست {norm:.0%}   «{ans}»")
    a, b = "بخشش لازم نیست، اعدامش کنید", "بخشش، لازم نیست اعدامش کنید"
    print(f"\n  «{a}»\n  «{b}»")
    print(f"  هم‌پوشانی کلمه: {word_overlap(a, b):.0%}   فاصلهٔ ویرایش: {edit_distance(a, b)} نویسه")


EVAL_SET = [
    ("مدل پایه چیست؟",
     "مدل پایه مدلی بزرگ و همه‌منظوره است که پایهٔ ساخت اپلیکیشن‌های گوناگون می‌شود.",
     Rubric(must_mention=["همه‌منظوره"])),
    ("توکن یعنی چه؟",
     "توکن واحد کار مدل است؛ چیزی بین حرف و کلمه.",
     Rubric(must_mention=["واحد", "کلمه"])),
    ("RAG چیست؟",
     "RAG یعنی پیش از پاسخ، متن مرتبط از منبع بیرونی بازیابی و به پرامپت افزوده شود.",
     Rubric(must_mention=["بازیابی", "پرامپت"])),
    ("GPT-3 با چند توکن آموزش دید؟",
     "GPT-3 با حدود 300 میلیارد توکن آموزش دید.",
     Rubric(must_mention=["میلیارد"])),
]


def part3_judge() -> None:
    print("\n═══ ۳. داور با معیار، روی پاسخ‌های ذهن‌یار ═══")
    bot, judge = Zehnyar(), RubricJudge()
    answers = [bot.ask(q).text for q, _, _ in EVAL_SET[:3]]
    # پاسخ چهارم را خودمان می‌سازیم: روان، مطمئن — و با یک عدد ساختگی
    answers.append("GPT-3 با حدود 10 تریلیون توکن آموزش دید، یعنی بیش از هر مدل دیگری.")
    total = 0
    for (q, ref, rubric), ans in zip(EVAL_SET, answers):
        r = judge.judge(q, ref, ans, rubric)
        total += r["score"]
        print(f"  {r['score']}/5  {q}")
        print(f"        {' · '.join(r['reasons'])}")
    print(f"  میانگین: {total / len(EVAL_SET):.2f} از ۵   (داور: {judge.name})")

    print("\n  همین معیار، به شکل پرامپت داور مدل‌محور (اگر مدل واقعی وصل باشد):")
    prompt = LLMJudge(MockProvider()).prompt(*EVAL_SET[3][:2], answers[3], EVAL_SET[3][2])
    for line in prompt.splitlines()[:4]:
        print(f"    | {line}")
    print("    | …")


def part4_position_bias() -> None:
    print("\n═══ ۴. سوگیری جایگاه اول ═══")
    q = "توکن یعنی چه؟"
    good = "توکن واحد کار مدل است؛ چیزی بین حرف و کلمه."
    bad = "توکن نوعی رمز عبور است."

    def biased(question, first, second):          # داوری که همیشه اولی را می‌پسندد
        return "first"

    def fair(question, first, second):            # داوری که واقعاً محتوا را می‌سنجد
        return "first" if "واحد" in first else "second"

    print(f"  داور سوگیرانه، یک بار: {'A' if biased(q, good, bad) == 'first' else 'B'}  (A درست است — تصادفاً)")
    print(f"  داور سوگیرانه، دو ترتیب: {compare_both_orders(biased, q, good, bad)}")
    print(f"  داور منصف، دو ترتیب:     {compare_both_orders(fair, q, good, bad)}")


def main() -> None:
    print("=" * 50)
    print(" ذهن‌یار v0.3 — فصل ۳")
    print("=" * 50)
    part1_perplexity()
    part2_similarity()
    part3_judge()
    part4_position_bias()


if __name__ == "__main__":
    main()
PowerShellpowershell
python -m projects.ch03_judge.run

خروجی واقعی — شما هم دقیقاً همین را می‌بینید:

خروجیtext
==================================================
 ذهن‌یار v0.3 — فصل ۳
==================================================

═══ ۱. سرگشتگی: مدل این متن را دیده؟ ═══
     192.7   جملهٔ خود دادهٔ آموزشی
            «حالا تصور کنید بخواهید یک رادیولوژیست هزاران عکس سی‌تی‌اسکن »
     327.4   جملهٔ تازه ولی معنادار
            «یک مدل کوچک هم می‌تواند متن فارسی را پیش‌بینی کند»
    2488.5   همان کلمه‌ها، درهم
            «بخواهید — غیرممکن عکس بزند یک عملاً را سی‌تی‌اسکن تصور حالا »

═══ ۲. شباهت واژگانی و یکدست‌سازی ═══
  خام 60%  ←  یکدست 100%   «گربه‌ی من موش را مي‌ترساند»
  خام 80%  ←  یکدست 80%   «گربهٔ من موش را می‌خورد»

  «بخشش لازم نیست، اعدامش کنید»
  «بخشش، لازم نیست اعدامش کنید»
  هم‌پوشانی کلمه: 100%   فاصلهٔ ویرایش: 2 نویسه

═══ ۳. داور با معیار، روی پاسخ‌های ذهن‌یار ═══
  5/5  مدل پایه چیست؟
        همهٔ معیارها برآورده شد
  5/5  توکن یعنی چه؟
        همهٔ معیارها برآورده شد
  3/5  RAG چیست؟
        نگفته: بازیابی
  1/5  GPT-3 با چند توکن آموزش دید؟
        نگفته: میلیارد · عدد بی‌پشتوانه: 10
  میانگین: 3.50 از ۵   (داور: rubric-judge-v1)

  همین معیار، به شکل پرامپت داور مدل‌محور (اگر مدل واقعی وصل باشد):
    | کار تو: به «پاسخ» از ۱ تا ۵ نمره بده، با توجه به «پاسخ مرجع».
    | معیارها:
    | - پاسخ باید این مفهوم‌ها را بگوید: میلیارد
    | - هر عددی که در پاسخ هست باید در مرجع هم باشد؛ عدد ساختگی = حداکثر نمرهٔ ۲.
    | …

═══ ۴. سوگیری جایگاه اول ═══
  داور سوگیرانه، یک بار: A  (A درست است — تصادفاً)
  داور سوگیرانه، دو ترتیب: مساوی (داور با جابه‌جایی نظرش عوض شد)
  داور منصف، دو ترتیب:     A

چه دیدیم؟

۱. سرگشتگی متن دیده‌شده را لو می‌دهد

جمله‌ای که در دادهٔ آموزشی بود سرگشتگی ۱۹۳ گرفت؛ جملهٔ تازه ولی معنادار ۳۲۷؛ و همان کلمه‌های جملهٔ اول، درهم‌ریخته، ۲٬۴۸۹. این دو کاربرد درس سنجه‌ها است، با عدد: سرگشتگی پایین‌تر از انتظار یعنی «احتمالاً دیده» (تشخیص نشت داده)، و سرگشتگی خیلی بالا یعنی «متن غیرعادی» (تشخیص متن خراب).

۲. یکدست‌سازی، و حد سنجهٔ واژگانی

«گربه‌ی من موش را مي‌ترساند» همان جملهٔ مرجع است، فقط با کسرهٔ «ـه‌ی» و «ي» عربی. بدون یکدست‌سازی ۶۰٪ گرفت؛ با آن ۱۰۰٪. ولی ببینید «گربهٔ من موش را می‌خورد» هنوز ۸۰٪ می‌گیرد — با معنایی کاملاً متفاوت. و دو جملهٔ ویرگول‌دار ۱۰۰٪ هم‌پوشانی و فقط ۲ نویسه فاصله دارند؛ معنایشان مرگ و زندگی است. هیچ سنجهٔ واژگانی این را نمی‌بیند.

۳. کارنامهٔ ذهن‌یار — و محدودیت داور قاعده‌محور

داور عدد ساختگی را گرفت: پاسخ «۱۰ تریلیون توکن» — روان و مطمئن — نمرهٔ ۱ گرفت، چون عدد ۱۰ در مرجع نیست و «میلیارد» هم نگفته. این همان توهمی است که در فصل ۲ دیدید، و داوری که برایش معیار داشت آن را گرفت.

۴. سوگیری جایگاه، با یک خط کد مهار شد

داور سوگیرانه که همیشه اولی را می‌پسندد، در یک بار پرسیدن «درست» جواب داد — تصادفاً، چون پاسخ درست اول بود. با دو ترتیب، معلوم شد هیچ تصمیمی نگرفته. داور منصف در هر دو ترتیب همان را انتخاب کرد.

تمرین شما

  1. داور را منعطف‌تر کنید

    به Rubric امکان «هم‌ارزها» بدهید — مثلاً must_mention='بازیابی', 'پیدا می‌کند' یعنی یکی از این‌ها کافی است. نمرهٔ پاسخ RAG ذهن‌یار چند می‌شود؟

  2. مجموعهٔ ارزیابی را بزرگ کنید

    برای هر هشت کلیدواژه‌ای که ذهن‌یار فصل ۱ بلد است یک پرسش، مرجع و معیار بنویسید. میانگین نمره را یادداشت کنید — این خط مبنای ذهن‌یار است؛ هر تغییر بعدی را با آن مقایسه می‌کنید.

  3. داور مدل‌محور را واقعاً اجرا کنید

    اگر Ollama یا کلید یک سرویس دارید، LLMJudge(get_provider('ollama')) را روی همان مجموعه اجرا کنید و نمره‌هایش را کنار نمره‌های RubricJudge بگذارید. کجا با هم فرق دارند؟ حق با کدام است؟

خودآزمایی

آزمونک ۱

چرا بدون هموارسازی نمی‌شود سرگشتگی یک جملهٔ تازه را حساب کرد؟

آزمونک ۲

داور قاعده‌محور به پاسخ درست ذهن‌یار دربارهٔ RAG نمرهٔ ۳ داد. درس اصلی این اتفاق چیست؟

آزمونک ۳

در این پروژه، ساده‌ترین نشانهٔ توهم که داور قاعده‌محور می‌گیرد چه بود؟

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

۵ کارت · ۰ مرور

وارد شوید تا پیشرفتتان ذخیره شود و از همین‌جا ادامه دهید.

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