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

فصل ۴ از ۹

پیشرفت ترم
۰٪

ترم ۱ · کدی که می‌شود به آن تکیه کرد

قرارداد: تایپ و اعتبارسنجی

فصل ۴پیش‌نمایش رایگان

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

سه فصل است هر تابعی که نوشته‌ایم، هر چیزی را که به آن بدهی قبول می‌کند. normalise(None) را امتحان کن: خطا می‌دهد — ولی نه آنجا که داده وارد شده، بلکه پنج قاب پایین‌تر، جایی که هیچ ربطی به منشأ مشکل ندارد.

این فصل قرارداد می‌نویسد. عدد اصلی‌اش این است: با یک قراردادِ زمانِ اجرا روی مرزِ ورودی، همان خطا از عمقِ ۵ قاب به ۲ قاب می‌آید و پیامش به‌جای «NoneType صفتِ replace ندارد» می‌شود «پارامترِ text باید str باشد، NoneType گرفت».

بعد هزینه‌اش را می‌شماریم و به یک معاملهٔ ناخوشایند می‌رسیم: قراردادِ ارزانِ سطحِ دسته با ۲ بررسی تمام می‌شود و هیچ ردیفِ خرابی نمی‌گیرد؛ قراردادی که ردیف‌به‌ردیف می‌سنجد — همان جنسی که آن هفت ردیف را گرفت — ۱۵۳۸ بررسی می‌کند: ۷۶۹ برابر. ارزانی و بی‌فایدگی اینجا یک چیزند، و قاعده‌ای که از دلش درمی‌آید دو نیمه دارد.

دهانهٔ ورودیِ یک لوله با قالبِ شکلی که فقط قطعهٔ درست از آن رد می‌شود

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

  • راهنمای تایپ بنویسی و بدانی دقیقاً چه‌کاری نمی‌کند
  • یک decorator بسازی که قرارداد را در زمانِ اجرا اعمال کند
  • تصمیم بگیری قرارداد روی کدام مرز بنشیند، با عدد نه با سلیقه
  • یک بررسیِ ایستای کوچک با ast بنویسی و بگویی چه چیزی از چشمش می‌افتد

قبل از شروع#

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

دامنهٔ این فصل عمداً باریک است: مرزِ تابع. اعتبارسنجیِ کاملِ داده — اسکیما، بازه، یکتایی، گزارشِ کیفیت — کارِ ترمِ ۲ است.

اجرا زمانِ تقریبی
CPU (پیش‌فرضِ Colab) کمتر از یک دقیقه

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

۱. راهنمای تایپ هیچ‌چیز را اجرا نمی‌کند#

def normalise(text: str) -> str:
    return text.replace("ي", "ی").replace("ك", "ک").strip()


def wrong(text: str) -> int:
    return "این یک رشته است، نه عدد"


print("راهنمای تایپ:", normalise.__annotations__)
print("خروجیِ تابعی که int وعده داده:", repr(wrong("x")), "→", type(wrong("x")).__name__)
راهنمای تایپ: {'text': <class 'str'>, 'return': <class 'str'>}
خروجیِ تابعی که int وعده داده: 'این یک رشته است، نه عدد' → str

تابعی که int وعده داده، str برگرداند و پایتون حتی مکث نکرد.

راهنمای تایپ در پایتون فقط یک یادداشت است که در __annotations__ ذخیره می‌شود. نه ورودی را بررسی می‌کند، نه خروجی را، نه سرعت را عوض می‌کند. پس چرا بنویسیمش؟ چون سه مصرف‌کننده دارد که هیچ‌کدام مفسر نیستند: خوانندهٔ کد، بررسیِ ایستا (بخشِ ۶)، و قراردادِ زمانِ اجرا (بخشِ ۳) که ما خودمان می‌سازیم و همین یادداشت‌ها را می‌خواند.

۲. هزینهٔ نبودِ قرارداد#

یک زنجیرهٔ چهارتایی می‌سازیم — دقیقاً همان شکلی که هر کدِ داده‌ای دارد — و یک ردیفِ خرابِ واقعی از پیکرهٔ کثیف را داخلش می‌اندازیم.

import traceback


def tokenise(text: str) -> list:
    return normalise(text).split()


def make_features(rows: list) -> list:
    return [tokenise(r["text"]) for r in rows]


def train(rows: list) -> int:
    return len(make_features(rows))


broken_rows = [{"text": "سلام", "label": "فنی"}, {"text": None, "label": "ارسال"}]

try:
    train(broken_rows)
except Exception as exc:
    frames = traceback.extract_tb(exc.__traceback__)
    print(f"{type(exc).__name__}: {exc}")
    print("عمقِ traceback:", len(frames), "قاب")
    print("جایی که ترکید:", frames[-1].name)
    print("جایی که دادهٔ بد وارد شد:", frames[1].name)
AttributeError: 'NoneType' object has no attribute 'replace'
عمقِ traceback: 5 قاب
جایی که ترکید: normalise
جایی که دادهٔ بد وارد شد: train

دو نامِ متفاوت در دو سطرِ آخر، کلِ مسئله را می‌گویند. خطا در normalise ترکید ولی normalise هیچ تقصیری ندارد — کارش را درست انجام داد روی چیزی که نباید به آن می‌رسید. متهمِ واقعی train است که یک ردیفِ بی‌متن را پذیرفت.

و پیامِ خطا هیچ کمکی نمی‌کند: «NoneType صفتِ replace ندارد» به تو نمی‌گوید کدام ردیف، از کدام فایل، با کدام شناسه. در یک اجرای شبانه روی صد هزار ردیف، این پیام یعنی یک ساعت جست‌وجو.

۳. checked: قراردادِ زمانِ اجرا#

حالا از همان راهنمای تایپی که «هیچ‌کاری نمی‌کند» یک نگهبان می‌سازیم. ابزارش decorator است و اگر تا حالا ندیده‌ای، در سه گام ساخته می‌شود.

گامِ اول — decorator چیزی جز این نیست: یک تابع که یک تابع می‌گیرد و یک تابعِ تازه برمی‌گرداند. تابعِ تازه معمولاً کاری می‌کند و بعد اصلی را صدا می‌زند. گامِ دوم *args, **kwargs است: چون wrapper نمی‌داند تابعِ اصلی چند آرگومان می‌گیرد، همه را دربست تحویل می‌گیرد و دربست پاس می‌دهد. گامِ سوم functools.wraps است، که یک عارضهٔ جانبیِ آزاردهنده را درست می‌کند:

import functools


def loud(fn):
    """کوچک‌ترین decoratorِ ممکن: یک تابع می‌گیرد و یک تابعِ تازه برمی‌گرداند."""
    def wrapper(*args, **kwargs):        # هر آرگومانی، بی‌آنکه بدانیم چندتاست
        print("  صدا زده شد:", fn.__name__)
        return fn(*args, **kwargs)
    return wrapper


noisy = loud(normalise)
print("خروجی:", repr(noisy("  كتاب  ")))
print("اسمِ تابعِ تازه:", noisy.__name__)


def loud_wrapped(fn):
    @functools.wraps(fn)                 # نام و مستندِ تابعِ اصلی را روی wrapper می‌گذارد
    def wrapper(*args, **kwargs):
        return fn(*args, **kwargs)
    return wrapper


print("با functools.wraps:", loud_wrapped(normalise).__name__)
  صدا زده شد: normalise
خروجی: 'کتاب'
اسمِ تابعِ تازه: wrapper
با functools.wraps: normalise

بدونِ functools.wraps، تابعِ تو اسمش را از دست می‌دهد و wrapper می‌شود. روی یک تابع بی‌اهمیت است؛ روی یک traceback یا یک پیامِ خطا که می‌خواهد بگوید کدام تابع شکست، فاجعه است. قاعده: هر decorator که می‌نویسی، @functools.wraps هم دارد.

و @loud بالای یک تابع، دقیقاً همان loud(...) است — یعنی @ فقط یک نوشتنِ کوتاه‌تر است، نه یک سازوکارِ تازه.

حالا همان الگو، این بار با یک کارِ واقعی داخلِ wrapper. دو ابزارِ تازه لازم می‌شود: inspect امضای تابع را می‌دهد (تا بدانیم کدام آرگومان اسمش چیست) و typing.get_type_hints یادداشت‌های تایپ را:

import inspect
import typing

CHECKS = 0


def checked(fn):
    hints = typing.get_type_hints(fn)
    sig = inspect.signature(fn)

    @functools.wraps(fn)
    def wrapper(*args, **kwargs):
        global CHECKS
        bound = sig.bind(*args, **kwargs)
        for name, value in bound.arguments.items():
            CHECKS += 1
            want = hints.get(name)
            if want is not None and not isinstance(value, want):
                raise TypeError(f"{fn.__name__}: پارامترِ {name} باید {want.__name__} باشد، "
                                f"{type(value).__name__} گرفت")
        result = fn(*args, **kwargs)
        want = hints.get("return")
        if want is not None:
            CHECKS += 1
            if not isinstance(result, want):
                raise TypeError(f"{fn.__name__}: خروجی باید {want.__name__} باشد، "
                                f"{type(result).__name__} گرفت")
        return result

    return wrapper


try:
    checked(normalise)(None)
except TypeError as exc:
    print("TypeError:", exc)

try:
    checked(wrong)("x")
except TypeError as exc:
    print("TypeError:", exc)
TypeError: normalise: پارامترِ text باید str باشد، NoneType گرفت
TypeError: wrong: خروجی باید int باشد، str گرفت

بیست‌وپنج خط، و حالا آن یادداشت‌ها اجرا می‌شوند.

sig.bind کارِ ظریفی می‌کند که ارزشِ دیدن دارد: آرگومان‌ها را به نامِ پارامترها می‌چسباند، چه با ترتیب داده باشی چه با نام. بدونِ آن باید خودت args و kwargs را با امضا تطبیق می‌دادی و همان‌جا باگ می‌گذاشتی.

⚠️ مواظب باش: این نگهبان فقط با تایپ‌های ساده کار می‌کند. list[str] را به isinstance بدهی، TypeError می‌گیری — چون list[str] یک شیءِ تایپ است نه یک کلاس. پس list بنویس، نه list[str]، و بدان که آن‌وقت قرارداد فقط می‌گوید «فهرست است»، نه «فهرستی از رشته». این محدودیت را در بخشِ بعد با عدد می‌بینی.

۴. قرارداد کجا بنشیند#

@checked
def load_batch(rows: list) -> list:
    return [{"text": normalise(r["text"]), "label": r["label"]} for r in rows]


@checked
def one_ticket(text: str) -> str:
    return normalise(text)


rejected, first_message = 0, ""
for t in MESSY:
    try:
        one_ticket(t["text"])
    except TypeError as exc:
        rejected += 1
        first_message = first_message or str(exc)

print("ردیف‌های ردشده در مرز:", rejected, "از", len(MESSY))
print("پیامِ اولین رد:", first_message)

try:
    load_batch(broken_rows)
except Exception as exc:
    frames = traceback.extract_tb(exc.__traceback__)
    print(f"قراردادِ سطحِ بالا  → {type(exc).__name__}، {len(frames)} قاب")

try:
    [one_ticket(r["text"]) for r in broken_rows]
except TypeError as exc:
    frames = traceback.extract_tb(exc.__traceback__)
    print(f"قراردادِ سطحِ ردیف → {type(exc).__name__}، {len(frames)} قاب")
ردیف‌های ردشده در مرز: 7 از 789
پیامِ اولین رد: one_ticket: پارامترِ text باید str باشد، NoneType گرفت
قراردادِ سطحِ بالا  → AttributeError، 4 قاب
قراردادِ سطحِ ردیف → TypeError، 2 قاب

دو سطرِ آخر همان محدودیتی است که در بخشِ قبل هشدار دادم، این بار با عدد.

load_batch قرارداد داشت و قراردادش پاس شد — چون broken_rows واقعاً یک list است. قرارداد فقط پوسته را دید، نه محتوا را، و خطا باز هم چهار قاب پایین‌تر ترکید. قراردادِ تایپ به عمقِ یک لایه است.

one_ticket روی خودِ ردیف نشسته بود، پس خطا در دومین قاب گرفته شد و پیامش دقیقاً گفت چه چیزی از چه نوعی بود.

📏 اندازه بگیر: با چه چیزی مقایسه شد؟ با همان زنجیره بدونِ قرارداد: ۵ قاب و پیامِ AttributeError. روی کدام داده؟ همان ردیفِ خرابِ ساختگی، به‌علاوهٔ کلِ ۷۸۹ ردیفِ پیکرهٔ کثیف که ۷تایشان رد شدند. با چند seed؟ هیچ‌کدام — این‌ها شمارشِ قاب و ردیف‌اند و هیچ تصادفی در کارشان نیست. هر عددی که تصادف داشته باشد در این دوره با چند seed گزارش می‌شود؛ هر عددی که نداشته باشد، نه. دانستنِ تفاوتشان بخشی از کار است.

۵. هزینهٔ قرارداد#

وسوسه‌ای که همه دارند: «پس بگذار همه‌جا باشد». بسنجیمش.

CHECKS = 0
rows = [{"text": t["text"], "label": t["label"]} for t in TICKETS]
load_batch(rows)
at_boundary = CHECKS

CHECKS = 0
checked_normalise = checked(normalise)
_ = [checked_normalise(r["text"]) for r in rows]
in_loop = CHECKS

print("قرارداد فقط سرِ در      :", at_boundary, "بررسی")
print("قرارداد داخلِ حلقه      :", in_loop, "بررسی")
print("نسبت                   :", in_loop // max(at_boundary, 1), "برابر")
قرارداد فقط سرِ در      : 2 بررسی
قرارداد داخلِ حلقه      : 1538 بررسی
نسبت                   : 769 برابر
import time


def clock(fn, repeat=5):
    best = float("inf")
    for _ in range(repeat):
        start = time.perf_counter()
        fn()
        best = min(best, time.perf_counter() - start)
    return best


bare = clock(lambda: [normalise(r["text"]) for r in rows])
guarded = clock(lambda: [checked_normalise(r["text"]) for r in rows])
print(f"بدونِ قرارداد: {bare * 1000:.1f} میلی‌ثانیه")
print(f"با قرارداد   : {guarded * 1000:.1f} میلی‌ثانیه")
print(f"هزینه        : {guarded / bare:.1f} برابر")
بدونِ قرارداد: 0.2 میلی‌ثانیه
با قرارداد   : 2.1 میلی‌ثانیه
هزینه        : 11.3 برابر

ده برابر کندتر شد — ولی حواست باشد این دو ستون دو چیزِ متفاوت را می‌خرند.

آن «۲ بررسی» مالِ قراردادِ load_batch است، و بخشِ ۴ همین چند خط بالاتر نشان داد که این قرارداد هیچ ردیفِ خرابی را نمی‌گیرد؛ فقط می‌گوید ورودی یک list است. پس ارزان بودنش هنر نیست: چیزی را نمی‌سنجد. آن هفت ردیف را one_ticket گرفت — قراردادی که روی خودِ ردیف نشسته — و هزینه‌اش دقیقاً از جنسِ همان ۱۵۳۸ بررسی است.

checked سه کارِ گران انجام می‌دهد — sig.bind، ساختنِ یک دیکشنری، و یک isinstance به‌ازای هر پارامتر — و همهٔ این‌ها به‌ازای هر فراخوانی تکرار می‌شوند در حالی که خودِ normalise فقط دو replace و یک strip است. این همان الگویی است که فصلِ ۶ و ۷ کلاً دربارهٔ آن‌اند: نگهبانی که از کارِ نگهبانی‌شده گران‌تر است.

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

🔧 اگر کار نکرد: اگر TypeError: isinstance() argument 2 cannot be a parameterized generic گرفتی، جایی list[str] یا dict[str, int] را در راهنمای تایپ گذاشته‌ای و checked سعی کرده آن را به isinstance بدهد. دو راه دارد: یا در قرارداد فقط تایپِ ساده بنویس (list)، یا در wrapper نوعِ پارامتری‌شده را با typing.get_origin(want) or want به کلاسِ پایه‌اش برگردان. دومی درست‌تر است و یک خط بیشتر نیست.

۶. بررسیِ ایستا: خواندنِ کد بدونِ اجرا#

قراردادِ زمانِ اجرا یک ضعفِ ذاتی دارد: باید آن خط اجرا شود تا خطا را ببیند. خطی که فقط در حالتِ نادر اجرا می‌شود، هفته‌ها ساکت می‌ماند.

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

import ast

SOURCE = '''
def normalise(text: str) -> str:
    return text.strip()


def summarise(rows: list) -> int:
    return len(rows)


print(normalise("سلام"))
print(normalise(None))
print(summarise("این یک رشته است"))
value = None
print(normalise(value))
'''

PY_TYPES = {"str": str, "int": int, "float": float, "list": list, "dict": dict, "bool": bool}


def static_check(source):
    tree = ast.parse(source)
    wants = {node.name: [PY_TYPES.get(getattr(a.annotation, "id", None))
                         for a in node.args.args]
             for node in ast.walk(tree) if isinstance(node, ast.FunctionDef)}
    problems = []
    for node in ast.walk(tree):
        if not (isinstance(node, ast.Call) and isinstance(node.func, ast.Name)):
            continue
        for want, arg in zip(wants.get(node.func.id, []), node.args):
            if want and isinstance(arg, ast.Constant) and not isinstance(arg.value, want):
                problems.append((node.lineno, node.func.id, want.__name__,
                                 type(arg.value).__name__))
    return problems


for line, name, want, got in static_check(SOURCE):
    print(f"  خط {line}: {name}(...) — {want} می‌خواست، {got} گرفت")
print("تعدادِ ایرادِ پیداشده:", len(static_check(SOURCE)))
  خط 11: normalise(...) — str می‌خواست، NoneType گرفت
  خط 12: summarise(...) — list می‌خواست، str گرفت
تعدادِ ایرادِ پیداشده: 2

دو ایراد، بدونِ اینکه حتی یک خط از آن کد اجرا شود. و مهم‌تر: بدونِ اینکه لازم باشد داده‌ای در کار باشد یا شرطی برقرار شود.

ولی سه فراخوانیِ مشکوک در آن متن بود، نه دو. سطرِ آخر normalise(value) است و value برابرِ None است. چرا ندیدش؟

DROP = ('print(normalise(None))', 'print(summarise("این یک رشته است"))')
runnable = "\n".join(line for line in SOURCE.splitlines() if line not in DROP)

try:
    exec(compile(runnable, "<source>", "exec"), {})
except Exception as exc:
    print(f"{type(exc).__name__}: {exc}")
    print("این همان فراخوانی‌ای است که بررسیِ ایستا ندید.")
سلام
AttributeError: 'NoneType' object has no attribute 'strip'
این همان فراخوانی‌ای است که بررسیِ ایستا ندید.

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

چک کن: اگر عددِ «تعدادِ ایرادِ پیداشده» پیشِ تو ۳ درآمد، احتمالاً isinstance(arg, ast.Constant) را برداشته‌ای و متغیرها را هم شمرده‌ای. آن‌وقت هر فراخوانیِ سالمی هم ایراد حساب می‌شود — و بررسیِ ایستایی که هشدارِ الکی می‌دهد، دقیقاً مثلِ تستی که بی‌دلیل قرمز می‌شود، بعد از دو هفته خاموش می‌شود.

۷. سه لایه، سه کار#

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

هیچ‌کدام جای دیگری را نمی‌گیرد. ارزان‌ترینش را همیشه بنویس، متوسطش را در دروازهٔ خودکار بگذار (ترمِ ۶)، و گران‌ترین را فقط روی مرز.

🤖 از دستیارت بپرس: «تفاوتِ TypeError و ValueError چیست و کدام‌شان برای دادهٔ نامعتبر درست است؟» جوابِ درست این است: TypeError برای نوعِ اشتباه، ValueError برای نوعِ درست ولی مقدارِ نامعتبر — مثلاً رشته‌ای که باید تاریخ باشد و نیست. بعد خودت این را امتحان کن: checked را طوری گسترش بده که علاوه بر تایپ، یک تابعِ شرطِ دلخواه هم بپذیرد و اگر شرط برقرار نبود ValueError بدهد. حالا کدام یک از آن ۷ ردیفِ ردشده جای دیگری می‌رود؟

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

کلمه تلفظ به حروف فارسی یعنی چه
type hint تایپ هینت یادداشتِ نوعِ پارامتر و خروجی، بدونِ اثر در زمانِ اجرا
static check استاتیک چک بررسیِ کد بدونِ اجرا کردنش
runtime contract ران‌تایم کانترکت شرطی که هنگامِ فراخوانی واقعاً بررسی می‌شود
decorator دکوریتور تابعی که تابعِ دیگری را می‌پیچد و رفتار اضافه می‌کند
ast ای‌اس‌تی درختِ ساختارِ کد، همان چیزی که پایتون از متن می‌سازد
boundary باندری مرزی که داده از بیرون واردِ سامانه می‌شود

تمرین‌ها

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

در فصل بعد#

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

فصلِ بعد اشکال‌یابی را به یک روش تبدیل می‌کند: pdb، دوبخشی‌کردنِ خطا، و کوچک‌ترین نمونهٔ بازتولیدکننده — که در آن یک ورودیِ خرابِ ۷۸۲ ردیفی را به‌طورِ خودکار تا یک ردیف کوچک می‌کنیم.

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

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