داربست — خودآموز مهندسیِ سامانه‌های هوش مصنوعی (پیشرفته)

فصل ۱ از ۸

پیشرفت ترم
۰٪

ترم ۵ · زنده نگه داشتن

پایش: چه چیزی را باید دید

فصل ۱پیش‌نمایش رایگان
۲۲ دقیقه مطالعه فصل ۱

در این فصل چه یاد می‌گیری#

چهل روز ترافیک را سرو می‌کنیم و دو داشبورد کنارِ هم می‌گذاریم. داشبوردِ سامانه از روزِ اول تا روزِ آخر سبز است: p95 روی ۳۶ میلی‌ثانیه ثابت می‌ماند و نرخِ خطا حدودِ دو در هزار.

داشبوردِ مدل در همان چهل روز ۱۰٫۸ واحد دقت از دست می‌دهد — از ۰٫۸۷۶۴ به ۰٫۷۶۷۹ — و هیچ‌کدام از آن متریک‌های سبز حتی یک بار تکان نمی‌خورند.

و ناراحت‌کننده‌ترین عددِ فصل این است: میانگینِ اطمینانِ مدل در همان بازه بالا هم رفت (۰٫۸۹۲۲ به ۰٫۹۰۵۸). روی گروهی که کاملاً خراب شده، دقت ۰٫۰۱۲۵ است و اطمینان ۰٫۹۶۶۵. مدل تقریباً همیشه غلط می‌گوید و تقریباً همیشه مطمئن است.

دو صفحهٔ عقربه‌دار روی یک ماشین، یکی سالم و یکی از کار افتاده

آخر این فصل می‌توانی:

  • پیشامد ثبت کنی و بگویی چرا متریکی که پیشامدش را ننوشته‌ای اصلاً وجود ندارد
  • صدک را با دست حساب کنی و بگویی چرا میانگینِ تأخیر دروغِ آرامی است
  • متریکِ سامانه را از متریکِ مدل جدا کنی و برای هرکدام بگویی چه چیزی را نمی‌بیند
  • متریکی را که به برچسب نیاز دارد از متریکی که ندارد تشخیص بدهی

قبل از شروع#

از ترمِ ۴ فصلِ ۴: ساعتِ مجازی. کلِ این ترم دربارهٔ زمان است — پنجرهٔ رانش، نرخِ هشدار، فاصلهٔ بازآموزی — و اگر این عددها به ساعتِ سیستم وصل باشند، هیچ‌کدامشان روی ماشینِ تو همان چیزی نمی‌شوند که اینجا نوشته شده. پس زمان از امروز تا آخرِ ترم یک عددِ داخلِ برنامه است. ساعت در سلولِ راه‌اندازیِ ترم آماده است.

از ترمِ ۴ فصلِ ۶: متریکِ تجمیعی خرابیِ یک زیرگروه را در خودش حل می‌کند. بخشِ ۶ همان درس است، این بار بدونِ هیچ انتشاری.

قاعدهٔ ثابتِ این ترم: سامانه‌ای که پایش نمی‌شود، خراب است و تو نمی‌دانی.

💡 سلولِ راه‌اندازیِ ترم چه چیزی به‌ت می‌دهد: هر هشت فصلِ این ترم روی همین‌ها سوارند و هیچ‌کدام دوباره تعریف نمی‌شوند. DAY_SIZE = 120 تعدادِ تیکتِ هر روز؛ TRAIN_DAYS = 30 روزهایی که مدلِ زنده رویشان آموزش دیده؛ SHIFT_DAY = 40 روزی که دنیا عوض می‌شود. day_rows(day, stream="A") ردیف‌های یک روز را می‌دهد و span(first, last, stream="A") ردیف‌های روزِ first تا last را پشتِ سرِ هم — بازه از چپ بسته و از راست باز است، دقیقاً مثلِ range، پس span(40, 70, "B") یعنی سی روز، از ۴۰ تا ۶۹. آن حرفِ آخر جریان است و سه مقدار دارد: "N" هیچ اتفاقی نمی‌افتد (گروهِ کنترل)، "A" ورودی عوض می‌شود، "B" قاعدهٔ برچسب عوض می‌شود — و هر سه تا روزِ ۴۰ عیناً یکی‌اند. TRAIN هم همان span(0, 30) است، از پیش آماده. Clock() ساعتِ مجازی است با now()، advance(seconds) و jump_to(moment) (ساعت هرگز عقب نمی‌رود). fit_model(rows, tfidf=False, ngram=(1,1), C=1.0) یک دسته‌بند برمی‌گرداند و normalise(text) همان نرمال‌سازِ ترمِ ۲ است. WORK هم پوشهٔ کارِ ترم روی دیسک.

💡 نکته: در این فصل برچسبِ درست را داریم، چون آزمایشگاهیم. در تولید برچسب دیر می‌رسد یا اصلاً نمی‌رسد. فصلِ ۳ همین عصا را از زیرِ دستت می‌کشد؛ اینجا از آن استفاده می‌کنیم تا اول ببینیم دقیقاً چه چیزی را از دست می‌دهیم.

📓 نوت‌بوک: نوت‌بوک این فصل را در Colab باز کن — همهٔ کدهای این فصل آماده و به‌ترتیب داخلش هست.

اجرا زمانِ تقریبی
کلِ نوت‌بوک روی CPU حدودِ یک دقیقه
GPU لازم نیست

۱. پیشامد: چیزی که هر متریک از آن ساخته می‌شود#

قبل از هر داشبوردی یک چیزِ خسته‌کننده لازم است: برای هر درخواست یک ردیف بنویس. به آن ردیف می‌گوییم پیشامد، و هر متریکی که تا آخرِ این ترم می‌سازیم فقط یک شمارش یا یک نسبت روی همین ردیف‌هاست.

import math
import random
import statistics

MODEL = fit_model(TRAIN)
CLASSES = list(MODEL.classes_)


def latency_ms(ticket_id, chars):
    """تأخیرِ ساختگی ولی قطعی: هر تیکت قرعهٔ خودش را دارد، پس روی هر ماشینی یکی است."""
    draw = random.Random(f"lat:{ticket_id}")
    ms = 18.0 + 0.11 * chars + 6.0 * draw.random()
    if draw.random() < 0.03:                       # دُمِ کند: صف پشتِ سرویس
        ms += 140.0 + 90.0 * draw.random()
    return round(ms, 1)


def serve_day(day, stream, version="1.0.0"):
    """یک روز ترافیک را سرو می‌کند و برای هر درخواست یک پیشامد برمی‌گرداند."""
    rows = day_rows(day, stream)
    clock = Clock(start=day * 86400.0)
    texts = [normalise(r["text"]) for r in rows]
    probs = MODEL.predict_proba(texts)
    events = []
    for row, text, p in zip(rows, texts, probs):
        clock.advance(86400.0 / DAY_SIZE)          # ترافیک یکنواخت در طولِ روز
        best = max(range(len(CLASSES)), key=lambda i: p[i])
        crashed = random.Random(f"err:{row['ticket_id']}").random() < 0.002
        events.append({
            "t": clock.now(),
            "ticket_id": row["ticket_id"],
            "version": version,
            "channel": row["channel"],
            "chars": len(text),
            "latency_ms": latency_ms(row["ticket_id"], len(text)),
            "ok": not crashed,
            "prediction": None if crashed else CLASSES[best],
            "confidence": None if crashed else round(float(p[best]), 4),
            "truth": row["category"],
        })
    return events


day30 = serve_day(30, "N")
print("پیشامدهای یک روز:", len(day30))
for key, value in day30[0].items():
    print(f"  {key:<11} {value}")
پیشامدهای یک روز: 120
  t           2592720.0
  ticket_id   TK-N-030-000
  version     1.0.0
  channel     چت
  chars       97
  latency_ms  33.4
  ok          True
  prediction  ارسال
  confidence  0.6609
  truth       ارسال

هر فیلدِ این ردیف بعداً یک متریک می‌سازد، و هیچ‌کدام را نمی‌شود بعداً از هوا درآورد: latency_ms تأخیر می‌دهد، ok نرخِ خطا، confidence معیارِ جانشینِ فصلِ ۳، و channel تفکیکِ زیرگروه.

و version همان شمارهٔ نسخه‌ای است که ترمِ ۴ فصلِ ۲ در هر پاسخ گذاشت. کارش اینجا یک چیز است: فردا که دو مدل هم‌زمان زنده باشند، بشود هر متریک را به مدلِ درست نسبت داد.

و truth تنها فیلدی است که در تولید نداری. بقیه را سرویس همان لحظه می‌داند؛ این یکی یا هفته‌ها بعد می‌رسد یا هرگز.

t از ساعتِ مجازی می‌آید نه از ساعتِ سیستم. ترافیکِ یکنواختِ روز یعنی هر ۷۲۰ ثانیه یک درخواست، و همین باعث می‌شود عددهای این فصل روی هر ماشینی دقیقاً همین‌ها باشند.

چک کن: به سرویسِ خودت نگاه کن و این سؤال را بپرس: «اگر همین حالا بخواهم بدانم دیروز مدل چند بار کدام دسته را گفت، جوابش کجاست؟» اگر جواب «باید کد اضافه کنم» است، آن متریک امروز وجود ندارد و برای دیروز هم هرگز نخواهد داشت. پیشامدِ ثبت‌نشده قابلِ بازسازی نیست.

و «ثبت» یعنی روی دیسک، نه در یک list داخلِ حافظه. فهرستِ بالا با پایانِ همین runtime می‌رود، دقیقاً همان‌طور که فرآیندِ سرویسِ تو با اولین ری‌استارت می‌رود. پس همین‌جا بنویسیمشان — با همان jsonl که ترمِ ۲ فصلِ ۲ برای این کار انتخاب کرد، چون به انتهای یک فایلِ jsonl می‌شود سطر اضافه کرد بدونِ بازخوانیِ کلِ فایل.

import json

LOG_DIR = WORK / "events"
LOG_DIR.mkdir(exist_ok=True)


def write_events(events):
    """چرخشِ روزانه: هر روز، فایلِ خودش."""
    days = {}
    for event in events:
        days.setdefault(int(event["t"] // 86400), []).append(event)
    for day, rows in days.items():
        with open(LOG_DIR / f"events-{day:05d}.jsonl", "a", encoding="utf-8") as fh:
            for row in rows:
                fh.write(json.dumps(row, ensure_ascii=False) + "\n")
    return sorted(days)


for day in range(30, 33):
    write_events(serve_day(day, "N"))
files = sorted(LOG_DIR.iterdir())
size = sum(f.stat().st_size for f in files)
lines = sum(sum(1 for _ in open(f, encoding="utf-8")) for f in files)
print("فایل‌ها:", [f.name for f in files])
print(f"سطرها: {lines} · بایت: {size:,}")
print(f"به ازای هر ۱۰۰۰ درخواست: {size / lines * 1000 / 1024:,.0f} کیلوبایت")
print(f"با {DAY_SIZE} درخواست در روز، نگه‌داریِ ۹۰ روزه:"
      f" {size / lines * DAY_SIZE * 90 / 1024 / 1024:,.1f} مگابایت")
فایل‌ها: ['events-00030.jsonl', 'events-00031.jsonl', 'events-00032.jsonl', 'events-00033.jsonl']
سطرها: 360 · بایت: 77,777
به ازای هر ۱۰۰۰ درخواست: 211 کیلوبایت
با 120 درخواست در روز، نگه‌داریِ ۹۰ روزه: 2.2 مگابایت

۲۱۱ کیلوبایت به ازای هر هزار درخواست. این عددی است که باید کنارِ مهلتِ نگه‌داریِ ترمِ ۲ فصلِ ۸ بگذاری، چون آن فصل تصمیم را می‌گیرد و این عدد قیمتش را می‌گوید: با ترافیکِ این ترم، نود روز سیاهه حدودِ ۲٫۲ مگابایت است — هیچ. با یک میلیون درخواست در روز، همان نود روز نزدیکِ ۲۰ گیگابایت می‌شود، و آن دیگر هیچ نیست. پس مهلتِ نگه‌داری فقط یک تعهدِ حقوقی نیست؛ یک قلمِ هزینه هم هست.

سه چیز را با هم انتخاب کردیم و هر سه تصمیم‌اند، نه پیش‌فرض: قالب jsonl است (سطربه‌سطر، قابلِ افزودن)، چرخش روزانه است (پس حذفِ روزهای کهنه یک unlink است، نه بازنویسیِ یک فایلِ غول)، و نامِ فایل شمارهٔ روز را دارد تا مرتب‌سازی و حذف بی‌ابهام باشد.

و به یک ریزه‌کاری دقت کن: سه روز نوشتیم و چهار فایل درآمد. آخرین پیشامدِ هر روز دقیقاً روی مرزِ نیمه‌شب می‌افتد و در فایلِ روزِ بعد می‌نشیند. مرزِ چرخش یک تصمیم است و باید نوشته شود — وگرنه روزی کسی روی سیاههٔ یک روز حساب می‌کند و یک ردیف کم می‌آورد و ساعت‌ها دنبالِ باگ می‌گردد.

🔧 اگر کار نکرد: اگر write_events را دو بار پشتِ سرِ هم اجرا کنی، همه‌چیز سبز می‌ماند و تعدادِ سطرها دو برابر می‌شود — چون فایل را با حالتِ "a" باز کرده‌ایم. این باگ هیچ خطایی نمی‌دهد و فقط به‌شکلِ «متریک‌هایم دو برابرِ واقعیت است» دیده می‌شود. در تولید جوابش همان یکسان‌توانیِ ترمِ ۴ فصلِ ۴ است: هر پیشامد یک کلیدِ یکتا دارد (ticket_id)، و کسی که سیاهه را می‌خواند باید بر اساسِ همان کلید تکراری‌ها را بیندازد.

۲. صدک، با بیست عدد#

قبل از اینکه سراغِ ۱۲۰ تیکت برویم، بیست تأخیرِ ساختگی را با دست نگاه کنیم.

def percentile(values, q):
    """صدکِ q ام به روشِ «نزدیک‌ترین رتبه»: کوچک‌ترین عددی که q درصدِ داده از آن بیشتر نیستند."""
    ordered = sorted(values)
    rank = math.ceil(q / 100 * len(ordered))
    return ordered[max(rank, 1) - 1]


SAMPLE = [21, 22, 22, 23, 24, 24, 25, 25, 26, 27,
          27, 28, 29, 30, 31, 33, 35, 38, 210, 240]
print("بیست تأخیرِ مرتب‌شده:", SAMPLE)
print(f"میانگین: {statistics.mean(SAMPLE):.1f}  ·  p50: {percentile(SAMPLE, 50)}"
      f"  ·  p95: {percentile(SAMPLE, 95)}")
print("کاربرانی که بدتر از میانگین دیدند:", sum(x > statistics.mean(SAMPLE) for x in SAMPLE),
      "از", len(SAMPLE))
بیست تأخیرِ مرتب‌شده: [21, 22, 22, 23, 24, 24, 25, 25, 26, 27, 27, 28, 29, 30, 31, 33, 35, 38, 210, 240]
میانگین: 47.0  ·  p50: 27  ·  p95: 210
کاربرانی که بدتر از میانگین دیدند: 2 از 20

میانگین ۴۷ است و هجده نفر از بیست نفر تجربه‌ای بهترش داشتند.

این خاصیتِ همهٔ تأخیرهاست: توزیعشان چوله است، دُمِ راست دارند، و میانگین چیزی را گزارش می‌کند که تقریباً هیچ‌کس تجربه‌اش نکرده. آن دو نفری که ۲۱۰ و ۲۴۰ گرفتند، همان‌هایی‌اند که شکایت می‌کنند.

p95 یعنی «۹۵ درصدِ درخواست‌ها از این بهتر بودند». روشِ ما ساده‌ترینِ ممکن است: مرتب کن، رتبهٔ ceil(0.95 × n) را بردار. روش‌های دیگری هم هست که میان‌یابی می‌کنند و جوابشان کمی فرق دارد — مهم این است که در کلِ سامانه یکی را انتخاب کنی و همه‌جا نگهش داری.

🌱 ریشه‌اش کجاست: صدک یک تعمیمِ چیزی است که قبلاً بلدی: میانه، همان p50 است — و ریشه، ترم ۶ فصل ۶ — میانگین، میانه، پراکندگی دقیقاً همین تفاوت را با یک مثالِ حقوق می‌سازد و به این می‌رسد: «میانگین همهٔ عددها را جمع می‌کند، پس هر عددِ پرتی کلِ نتیجه را می‌کشد. میانه فقط به ترتیب نگاه می‌کند.» آن دو تأخیرِ ۲۱۰ و ۲۴۰ همان «عددِ پرت»اند. کاری که ما اینجا اضافه می‌کنیم یک قدم جلوتر است: در پایشِ سرویس، عمداً به‌جای وسط، سراغِ همان دُم می‌رویم — چون کاربرِ ناراضی آنجاست، نه در میانه.

۳. دو خانوادهٔ متریک#

def system_metrics(events):
    """متریکِ سامانه: هیچ‌کدام نمی‌دانند مدل چه گفت — فقط می‌دانند سرویس چه کرد."""
    lat = [e["latency_ms"] for e in events]
    return {
        "requests": len(events),
        "error_rate": sum(not e["ok"] for e in events) / len(events),
        "mean_ms": round(statistics.mean(lat), 1),
        "p50_ms": percentile(lat, 50),
        "p95_ms": percentile(lat, 95),
        "p99_ms": percentile(lat, 99),
        "max_ms": max(lat),
        "slow": sum(x > 100 for x in lat),
    }


def model_metrics(events):
    """متریکِ مدل: هیچ‌کدام نمی‌دانند سرویس چقدر سریع بود — فقط می‌دانند چه تصمیمی گرفت."""
    served = [e for e in events if e["ok"]]
    conf = [e["confidence"] for e in served]
    mix = {c: sum(e["prediction"] == c for e in served) / len(served) for c in CLASSES}
    return {
        "accuracy": round(sum(e["prediction"] == e["truth"] for e in served) / len(served), 4),
        "mean_conf": round(statistics.mean(conf), 4),
        "low_conf": round(sum(c < 0.60 for c in conf) / len(conf), 4),
        "top_share": round(max(mix.values()), 4),
    }


print("سامانه:", system_metrics(day30))
print("مدل   :", model_metrics(day30))
سامانه: {'requests': 120, 'error_rate': 0.0, 'mean_ms': 28.6, 'p50_ms': 28.5, 'p95_ms': 34.9, 'p99_ms': 35.8, 'max_ms': 36.5, 'slow': 0}
مدل   : {'accuracy': 0.8667, 'mean_conf': 0.89, 'low_conf': 0.1, 'top_share': 0.3833}

دو نکته در این خروجی هست که هر دو مهم‌اند.

۱) در تمامِ آن روز، slow صفر است و max_ms تنها ۳۶٫۵ — یعنی هیچ درخواستِ کندی نیفتاد. ولی ما با دستِ خودمان سه درصد دُمِ کند در کد گذاشته‌ایم، یعنی به‌طورِ متوسط سه‌چهار تا در هر ۱۲۰ درخواست. این روز اتفاقاً هیچ‌کدام را نگرفت، و همین‌جاست که خطر است: با ۱۲۰ درخواست، p99 یعنی «دومین کندترین»، و یک روزِ خالی از دُم هیچ چیزی دربارهٔ دُم ثابت نمی‌کند. صدکی که روی نمونهٔ کوچک حساب می‌شود، دربارهٔ دُم چیزی نمی‌داند. بخشِ بعد همین صدک را روی ۱۲۰۰ درخواست می‌گیرد و جوابِ کاملاً متفاوتی می‌گیرد.

۲) دو تابعِ بالا عمداً از هم بی‌خبرند. system_metrics نمی‌داند مدل چه گفت و model_metrics نمی‌داند سرویس چقدر طول کشید. این جدایی خودِ درسِ فصل است: یکی سلامتِ سرویس را می‌سنجد و دیگری سلامتِ تصمیم. سرویس می‌تواند بی‌عیب باشد و تصمیم فاجعه.

🔧 اگر کار نکرد: اگر mean_conf را روی همهٔ پیشامدها حساب کنی و نه فقط served، این را می‌گیری: TypeError: can't convert type 'NoneType' to numerator/denominator. پیامش هیچ نمی‌گوید که مقصر کدام ردیف است. علتش این است که درخواستِ شکست‌خورده confidence=None دارد. و این یک باگِ ساده نیست، یک تصمیمِ طراحی است: درخواستی که اصلاً سرو نشده، متریکِ مدل ندارد. اگر به‌جای فیلتر کردن، None را صفر بگیری، دقت و اطمینانت را با شکستِ سرویس رقیق کرده‌ای و دو خرابیِ کاملاً متفاوت را در یک عدد قاطی می‌کنی.

۴. چهل روز، دو داشبورد#

حالا همان دو خانواده را روی چهل روز اجرا می‌کنیم. روزهای ۳۰ تا ۳۹ سالم‌اند؛ از روزِ ۴۰ در این جریان چیزی عوض شده.

BLOCKS = [(30, 40), (40, 50), (50, 60), (60, 70)]


def block_report(stream):
    out = []
    for first, last in BLOCKS:
        events = [e for day in range(first, last) for e in serve_day(day, stream)]
        out.append((f"{first}-{last - 1}", system_metrics(events), model_metrics(events)))
    return out


print(f"{'روزها':<8}{'p95':>7}{'p99':>7}{'نرخِ خطا':>10}"
      f"{'دقت':>9}{'اطمینان':>10}{'کم‌اطمینان':>12}")
for label, sysm, modm in block_report("B"):
    print(f"{label:<8}{sysm['p95_ms']:>7.1f}{sysm['p99_ms']:>7.1f}{sysm['error_rate']:>10.4f}"
          f"{modm['accuracy']:>9.4f}{modm['mean_conf']:>10.4f}{modm['low_conf']:>12.4f}")
روزها       p95    p99  نرخِ خطا      دقت   اطمینان  کم‌اطمینان
30-39      36.7  213.4    0.0025   0.8764    0.8922      0.1011
40-49      36.2  236.0    0.0033   0.7358    0.9006      0.0794
50-59      35.6  206.8    0.0008   0.7490    0.8963      0.0942
60-69      35.6  225.6    0.0017   0.7679    0.9058      0.0760

سه ستونِ اول ثابت‌اند. ستونِ چهارم بینِ بلوکِ اول و دوم چهارده واحد سقوط می‌کند.

اگر روزِ چهلم یک آدمِ کشیک به این داشبورد نگاه کند و فقط سه ستونِ اول را داشته باشد، درست خواهد گفت که «سامانه سالم است» — چون واقعاً هست. سرویس بالاست، سریع است، خطا نمی‌دهد. فقط جواب‌هایش غلط شده.

و p99 را با بخشِ قبل مقایسه کن: روی یک روز ۳۵٫۸ بود، روی ده روز ۲۱۳٫۴. همان متریک، همان کد، شش برابر تفاوت — چون این بار نمونه به‌اندازهٔ کافی بزرگ بود که دُم را ببیند. هر وقت صدکی گزارش می‌کنی، تعدادِ نمونه‌اش را کنارش بنویس، وگرنه عدد بی‌معناست.

۵. گروهِ کنترل: چه چیزی واقعاً تکان خورد#

یک عددِ خراب به‌تنهایی چیزی ثابت نمی‌کند. باید بدانیم اگر هیچ اتفاقی نیفتاده بود همان عددها چقدر می‌شدند.

control = block_report("N")
drifted = block_report("B")
print("جریانِ کنترل (هیچ اتفاقی نیفتاده):")
for label, sysm, modm in control:
    print(f"  روزهای {label:<7} دقت {modm['accuracy']:.4f}  p95 {sysm['p95_ms']:.1f}")
first_sys = drifted[0][1]
last_sys = drifted[-1][1]
first_mod = drifted[0][2]
last_mod = drifted[-1][2]
print("جریانِ خراب، از بلوکِ اول تا آخر:")
print(f"  p95        : {first_sys['p95_ms']:.1f} → {last_sys['p95_ms']:.1f}"
      f"  (تغییر {last_sys['p95_ms'] - first_sys['p95_ms']:+.1f} میلی‌ثانیه)")
print(f"  نرخِ خطا    : {first_sys['error_rate']:.4f} → {last_sys['error_rate']:.4f}")
print(f"  اطمینان    : {first_mod['mean_conf']:.4f} → {last_mod['mean_conf']:.4f}"
      f"  (تغییر {last_mod['mean_conf'] - first_mod['mean_conf']:+.4f})")
print(f"  دقت        : {first_mod['accuracy']:.4f} → {last_mod['accuracy']:.4f}"
      f"  (تغییر {100 * (last_mod['accuracy'] - first_mod['accuracy']):+.1f} واحد)")
جریانِ کنترل (هیچ اتفاقی نیفتاده):
  روزهای 30-39   دقت 0.8766  p95 35.3
  روزهای 40-49   دقت 0.8779  p95 35.8
  روزهای 50-59   دقت 0.8870  p95 35.7
  روزهای 60-69   دقت 0.8965  p95 35.3
جریانِ خراب، از بلوکِ اول تا آخر:
  p95        : 36.7 → 35.6  (تغییر -1.1 میلی‌ثانیه)
  نرخِ خطا    : 0.0025 → 0.0017
  اطمینان    : 0.8922 → 0.9058  (تغییر +0.0136)
  دقت        : 0.8764 → 0.7679  (تغییر -10.8 واحد)

گروهِ کنترل خودش دو واحد بالا و پایین می‌رود — از ۰٫۸۷۶۶ تا ۰٫۸۹۶۵ — و هیچ اتفاقی هم نیفتاده. این عدد را یادت باشد؛ اسمش نوسانِ عادی است و فصلِ ۴ کلِ ماجرای هشدار را روی همین می‌سازد. یک افتِ یک‌واحدی خبر نیست؛ یک افتِ یازده‌واحدی هست.

و حالا خطِ سوم: میانگینِ اطمینان چهارده هزارم بالا رفت.

این را باید دو بار خواند. مدل ده واحد دقت از دست داده و مطمئن‌تر شده. دلیلش هم پیچیده نیست: ورودی‌ها دقیقاً همان‌هایی‌اند که همیشه بودند، مدل همان جوابِ همیشگی را با همان اطمینانِ همیشگی می‌دهد؛ چیزی که عوض شده جوابِ درست است، نه سؤال.

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

۶. کجا خراب است، نه چقدر#

عددِ تجمیعی گفت «ده واحد». حالا بپرسیم کجا.

def channel_accuracy(events, channel):
    picked = [e for e in events if e["ok"] and e["channel"] == channel]
    return sum(e["prediction"] == e["truth"] for e in picked) / len(picked), len(picked)


late = [e for day in range(60, 70) for e in serve_day(day, "B")]
print(f"{'کانال':<9}{'دقت':>9}{'نمونه':>8}")
for channel in ("تلفن", "ایمیل", "چت", "حضوری"):
    value, n = channel_accuracy(late, channel)
    print(f"{channel:<9}{value:>9.4f}{n:>8}")
wallet_ids = {r["ticket_id"] for day in range(60, 70) for r in day_rows(day, "B") if r["wallet"]}
hit = [e for e in late if e["ok"] and e["ticket_id"] in wallet_ids]
print("شکایتِ کیف پول:", f"{sum(e['prediction'] == e['truth'] for e in hit) / len(hit):.4f}",
      "· نمونه:", len(hit))
print("اطمینانِ مدل روی همان‌ها:",
      f"{statistics.mean(e['confidence'] for e in hit):.4f}")
کانال          دقت   نمونه
تلفن        0.7490     482
ایمیل       0.8098     368
چت          0.7535     288
حضوری       0.7333      60
شکایتِ کیف پول: 0.0125 · نمونه: 160
اطمینانِ مدل روی همان‌ها: 0.9665

تفکیک به کانال تقریباً هیچ نگفت. هر چهار کانال بینِ ۰٫۷۳ و ۰٫۸۱ افتاده‌اند؛ خرابی همه‌جا پخش است.

تفکیکِ درست چیزِ دیگری بود: روی ۱۶۰ شکایتِ مربوط به کیف پول و کد تخفیف، دقت ۰٫۰۱۲۵ است — یعنی از هر ۸۰ تا یکی — و اطمینانِ مدل روی همان‌ها ۰٫۹۶۶۵.

درسِ سختِ این بخش این است که بُعدِ درستِ تفکیک را از قبل نمی‌دانی. کانال بدیهی‌ترین بود و بی‌فایده؛ آنچه کار کرد موضوعِ تیکت بود. هیچ داشبوردی همهٔ برش‌های ممکن را از قبل ندارد — کاری که می‌شود کرد نگه‌داشتنِ پیشامدِ خام است تا فردا بشود برشِ تازه‌ای زد.

📏 اندازه بگیر: با چه چیزی مقایسه شد؟ با جریانِ کنترل — همان روزها، همان مدل، بدونِ هیچ تغییری در دنیا. بدونِ آن، «دقت ۰٫۷۶ شد» فقط یک عدد بود، نه یک خبر. روی کدام داده؟ چهل روز، هر روز ۱۲۰ تیکت، و بلوک‌های ده‌روزه تا هر عدد ۱۲۰۰ نمونه پشتش داشته باشد. با چند seed؟ یکی برای هر روز، و قرعهٔ هر روز فقط به شمارهٔ روز بستگی دارد — پس روی هر ماشینی همین عددها درمی‌آید. ولی گروهِ کنترل چهار بلوک دارد و پراکندگیِ خودش را نشان می‌دهد؛ آن پراکندگی نقشِ چند seed را بازی می‌کند.

۷. فهرستی که باید بنویسی#

از این فصل چیزی که باقی می‌ماند یک ابزار نیست، یک فهرست است:

  • ۱) متریکِ سامانه — نرخِ درخواست، نرخِ خطا، p50 و p95 و p99 تأخیر. این‌ها می‌گویند سرویس زنده است.
  • ۲) متریکِ مدلِ بی‌نیاز از برچسب — توزیعِ پیش‌بینی، میانگینِ اطمینان، نرخِ کم‌اطمینان. همان لحظه در دسترس‌اند و همان لحظه هم می‌توانند دروغ بگویند — یکی‌شان در جهتِ اشتباه حرکت کرد.
  • ۳) متریکِ مدلِ نیازمندِ برچسب — دقت، F1، ماتریسِ درهم‌ریختگی. حقیقت را می‌گویند و دیر می‌رسند.
  • ۴) همهٔ سه‌تای بالا، به تفکیکِ زیرگروه. بدونِ این، عددِ تجمیعی خرابیِ ۰٫۰۱۲۵ را در ۰٫۷۶ حل می‌کند.

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

🤖 از دستیارت بپرس: «تفاوتِ SLI و SLO و SLA چیست و کدامشان یک تعهدِ حقوقی است؟» بعد این را بپرس: «اگر بخواهم برای یک سرویسِ مدل یک SLO بنویسم که به دقت مربوط باشد نه به تأخیر، مشکلش چیست؟» — جوابِ درست به همان جایی می‌رسد که این فصل رسید: دقت دیر می‌آید، پس SLO رویش یک تعهد به چیزی است که تا هفته‌ها بعد نمی‌دانی نقض شده یا نه.

واژه‌های تازهٔ این فصل#

کلمه تلفظ به حروف فارسی یعنی چه
observability آبزرویبیلیتی اینکه از بیرون بشود فهمید داخلِ سامانه چه می‌گذرد
event اِوِنت ردیفِ ثبت‌شدهٔ یک اتفاق؛ همان پیشامد
percentile پرسنتایل عددی که درصدِ مشخصی از داده‌ها از آن کمترند
p95 / p99 پی نودوپنج / پی نودونه صدکِ ۹۵ و ۹۹ تأخیر — تجربهٔ بدترین کاربران
tail latency تیل لیتنسی تأخیرِ آن اقلیتِ کندی که میانگین پنهانشان می‌کند
error rate ارور ریت نسبتِ درخواست‌هایی که سرویس نتوانست جواب بدهد
system metric سیستم متریک متریکی که سلامتِ سرویس را می‌سنجد، نه کیفیتِ تصمیم را
model metric مدل متریک متریکی که کیفیتِ تصمیم را می‌سنجد، نه سلامتِ سرویس را

تمرین‌ها

اول خودت فکر کن یا امتحان کن — بعد اینجا را باز کن.

در فصل بعد#

دیدیم که مدل خراب شد و هیچ متریکِ سامانه‌ای نفهمید. فصلِ بعد سراغِ متریک‌هایی می‌رود که ادعا می‌کنند این را می‌فهمند: آشکارسازهای رانش.

و همان‌جا ناخوشایندترین اندازه‌گیریِ ترم را می‌گیریم: دو جریان می‌سازیم، یکی بی‌ضرر و یکی مرگبار، و آشکارساز روی بی‌ضرر با شدت زنگ می‌زند و روی مرگبار کاملاً ساکت می‌ماند. عددهایش PSI برابرِ ۰٫۹۸۴۰ در برابرِ ۰٫۰۰۵۰ است — و آن دومی دقیقاً همان عددی است که برای «هیچ اتفاقی نیفتاده» می‌گیرد.

به آخر این فصل رسیدی!

اگر ساختی و جواب داد، این دکمه مال توست.