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

فصل ۱ از ۹

پیشرفت ترم
۰٪

ترم ۲ · داده به‌عنوان زیرساخت

مسیرِ داده: از منبع تا ویژگی

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

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

یک اسکریپتِ کاملاً درست را دو بار اجرا می‌کنیم. بارِ اول ۱۲۳۵ ردیف می‌نویسد، بارِ دوم فایل ۲۴۷۰ ردیف دارد. هیچ خطایی هم نمی‌دهد — و این بدترین قسمتش است.

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

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

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

  • تفاوتِ یک اسکریپت و یک مسیرِ داده را با یک آزمایش نشان بدهی
  • مرحله را با ورودی و خروجیِ اعلام‌شده تعریف کنی
  • ترتیبِ اجرا را از وابستگی‌ها بسازی، نه از ترتیبی که کد را نوشته‌ای
  • برای هر ستون بگویی از کدام مرحله و از چند ردیف آمده است

قبل از شروع#

از ترمِ ۱: پروژه‌ای که ماژول دارد و تست دارد. اینجا همان انضباط را روی داده می‌گذاریم.

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

💡 نکته: پیکرهٔ ترم را سلولِ راه‌اندازی می‌سازد: ۱۲۴۰ تیکتِ پشتیبانیِ فارسی که عمداً کثیف‌اند. هر نُه فصلِ این ترم روی همین داده کار می‌کنند، پس عددهایت با کتاب و با فصل‌های بعدی قابلِ مقایسه است.

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

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

۱. اسکریپتی که کار می‌کند — تا وقتی دوباره اجرایش کنی#

این کدْ کارِ درستی می‌کند: تیکت‌ها را می‌خواند، ردیفِ بی‌متن را کنار می‌گذارد، و برای هر تیکت تعدادِ کلمه را می‌نویسد.

import csv

RAW = WORK / "raw" / "tickets.csv"
OUT = WORK / "naive_features.csv"


def naive_script():
    """همان کاری که همه اولین بار می‌کنند: بخوان، تمیز کن، بنویس."""
    with open(RAW, encoding="utf-8") as fh:
        rows = list(csv.DictReader(fh))
    keep = [r for r in rows if r["text"].strip()]
    exists = OUT.exists()
    with open(OUT, "a", encoding="utf-8", newline="") as fh:
        w = csv.DictWriter(fh, fieldnames=["ticket_id", "words", "category"])
        if not exists:
            w.writeheader()
        for r in keep:
            w.writerow({"ticket_id": r["ticket_id"],
                        "words": len(r["text"].split()),
                        "category": r["category"]})
    return len(rows), len(keep)


def count_rows(path):
    with open(path, encoding="utf-8") as fh:
        return sum(1 for _ in fh) - 1


read, kept = naive_script()
print("اجرای اول:", read, "خوانده،", kept, "نوشته · فایل:", count_rows(OUT), "ردیف")
naive_script()
print("اجرای دوم:", read, "خوانده،", kept, "نوشته · فایل:", count_rows(OUT), "ردیف")
اجرای اول: 1240 خوانده، 1235 نوشته · فایل: 1235 ردیف
اجرای دوم: 1240 خوانده، 1235 نوشته · فایل: 2470 ردیف

هر دو اجرا گزارش می‌دهند «۱۲۳۵ ردیف نوشتم». فایل ۲۴۷۰ ردیف دارد.

اسکریپت دروغ نمی‌گوید؛ واقعاً ۱۲۳۵ ردیف نوشته. مشکل این است که نمی‌داند قبلاً هم نوشته بوده. یک اسکریپت حافظه ندارد: هر بار از صفر شروع می‌کند و همان دستورها را از اول اجرا می‌کند.

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

نامِ فنیِ چیزی که این اسکریپت ندارد، «هم‌توانی» است: اجرای دوباره باید همان وضعیتی را بسازد که اجرای اول ساخت، نه چیزی روی آن.

۲. مرحله: کوچک‌ترین واحدی که می‌شود دوباره اجرایش کرد#

یک مرحله سه چیز را اعلام می‌کند: چه فایل‌هایی می‌خوانَد، چه فایل‌هایی می‌سازد، و با چه کدی. همین اعلام‌کردن است که همه‌چیزِ بعدی را ممکن می‌کند.

import hashlib
import inspect
import json
import time

STAGE_DIR = WORK / "stage"
STAGE_DIR.mkdir(exist_ok=True)
STATE = WORK / "state.json"
LOG = WORK / "provenance.jsonl"


def file_hash(path):
    """اثرِ انگشتِ محتوای یک فایل — پایهٔ همهٔ تصمیم‌های این راه‌انداز."""
    h = hashlib.sha256()
    with open(path, "rb") as fh:
        for block in iter(lambda: fh.read(1 << 16), b""):
            h.update(block)
    return h.hexdigest()


class Stage:
    def __init__(self, name, inputs, outputs, fn):
        self.name, self.inputs, self.outputs, self.fn = name, inputs, outputs, fn

    def key(self):
        """ورودی‌ها + خودِ کد. اگر هرکدام عوض شود، مرحله دیگر تازه نیست."""
        parts = [file_hash(p) for p in self.inputs] + [inspect.getsource(self.fn)]
        return hashlib.sha256("|".join(parts).encode("utf-8")).hexdigest()[:12]


def read_csv(path):
    with open(path, encoding="utf-8") as fh:
        return list(csv.DictReader(fh))


def write_csv(path, rows, fields):
    with open(path, "w", encoding="utf-8", newline="") as fh:
        w = csv.DictWriter(fh, fieldnames=fields)
        w.writeheader()
        w.writerows(rows)


print("اثرِ انگشتِ فایلِ خام:", file_hash(RAW)[:12])
اثرِ انگشتِ فایلِ خام: fa917db4b366

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

inspect.getsource متنِ تابع را برمی‌گرداند — همان چیزی که در سلولِ نوت‌بوک نوشته‌ای. اثرِ انگشتِ محتوا هم sha256 است: دو فایل با بایت‌های یکسان همیشه یک اثرِ انگشت دارند، و یک بایت اختلاف کلِ رشته را عوض می‌کند. فصلِ ۴ کاملاً روی همین ساخته می‌شود.

💡 از یک اثرِ انگشت سه چیز می‌خواهیم، و هر سه دلیلِ یک تصمیمِ بعدی‌اند. ۱) قطعی باشد: همان بایت‌ها، همیشه همان رشته — روی هر ماشین و هر اجرا. ۲) یکنواخت پخش شود: هیچ الگویی در ورودی نباید مقدارها را در یک گوشه جمع کند، وگرنه دو دادهٔ بی‌ربط یک اثرِ انگشت می‌گیرند. ۳) با یک بایت اختلاف، کاملاً عوض شود — نه کمی، بلکه به‌شکلی که هیچ شباهتی به قبلی نداشته باشد؛ وگرنه «تقریباً شبیه» با «یکسان» قاطی می‌شود. و چرا [:12] کافی است؟ هر نویسهٔ hex ۱۶ حالت دارد، پس ۱۲ نویسه یعنی 16 ** 12 — بیش از ۲۸۱ هزار میلیارد مقدارِ ممکن. برای فهرستی که چند هزار نسخه دارد این عدد آن‌قدر بزرگ است که برخوردِ اتفاقی عملاً ناممکن باشد. این حساب را نگه دار: ترمِ ۴ فصلِ ۶ دقیقاً روی همین سه خاصیت تصمیم می‌گیرد که hash() پایتون را برای تخصیصِ ترافیک کنار بگذارد و sha256 بگذارد — چون hash() در خاصیتِ اول می‌افتد، نه در دومی.

۳. پنج مرحله، و ترتیبی که اهمیت ندارد#

RAW_FIELDS = ["ticket_id", "customer_id", "created_at", "channel", "branch_code",
              "text", "category", "escalated", "rating", "handling_minutes"]


def dedup(inputs, outputs):
    rows, seen, out = read_csv(inputs[0]), set(), []
    for r in rows:
        key = tuple(r[f] for f in RAW_FIELDS)
        if key not in seen:
            seen.add(key)
            out.append(r)
    write_csv(outputs[0], out, RAW_FIELDS)
    return len(rows), len(out)


def typed(inputs, outputs):
    rows = read_csv(inputs[0])
    for r in rows:
        r["rating"] = r["rating"] if r["rating"].isdigit() else ""
        r["handling_minutes"] = int(r["handling_minutes"])
    write_csv(outputs[0], rows, RAW_FIELDS)
    return len(rows), len(rows)


def normalized(inputs, outputs):
    rows = read_csv(inputs[0])
    for r in rows:
        r["text"] = " ".join(r["text"].replace("ي", "ی").replace("ك", "ک").split())
    write_csv(outputs[0], rows, RAW_FIELDS)
    return len(rows), len(rows)


def valid(inputs, outputs):
    rows = read_csv(inputs[0])
    out = [r for r in rows
           if r["text"] and int(r["handling_minutes"]) > 0
           and r["created_at"] < "2032-01-01"]
    write_csv(outputs[0], out, RAW_FIELDS)
    return len(rows), len(out)


def features(inputs, outputs):
    rows = read_csv(inputs[0])
    out = [{"ticket_id": r["ticket_id"], "customer_id": r["customer_id"],
            "words": len(r["text"].split()), "channel": r["channel"],
            "category": r["category"], "escalated": r["escalated"]}
           for r in rows]
    write_csv(outputs[0], out, ["ticket_id", "customer_id", "words",
                                "channel", "category", "escalated"])
    return len(rows), len(out)


S = STAGE_DIR
PIPELINE = [
    Stage("features", [S / "valid.csv"], [S / "features.csv"], features),
    Stage("dedup", [RAW], [S / "dedup.csv"], dedup),
    Stage("typed", [S / "dedup.csv"], [S / "typed.csv"], typed),
    Stage("normalized", [S / "typed.csv"], [S / "normalized.csv"], normalized),
    Stage("valid", [S / "normalized.csv"], [S / "valid.csv"], valid),
]
BY_NAME = {s.name: s for s in PIPELINE}
print("به ترتیبی که نوشته شدند:", [s.name for s in PIPELINE])
به ترتیبی که نوشته شدند: ['features', 'dedup', 'typed', 'normalized', 'valid']

عمداً بد نوشتمشان: features اول است و ورودی‌اش را مرحله‌ای می‌سازد که آخر آمده. حالا ساده‌ترین راه‌انداز را امتحان کن — یعنی «به ترتیب اجرا کن».

from pathlib import Path

try:
    for stage in PIPELINE:
        stage.fn(stage.inputs, stage.outputs)
except FileNotFoundError as e:
    print("FileNotFoundError:", Path(e.filename).name)
FileNotFoundError: valid.csv

🔧 اگر کار نکرد: پیامِ کاملی که پایتون می‌دهد این است: FileNotFoundError: [Errno 2] No such file or directory: '/tmp/darbast-t2/stage/valid.csv'. ما فقط نامِ فایل را چاپ کردیم چون مسیرِ کاملْ روی ماشینِ تو فرق دارد. این خطا خوب است؛ خرابیِ بی‌صدای بخشِ ۱ خیلی بدتر بود. اگر به‌جای این خطا هیچ اتفاقی نیفتاد، یعنی از اجرای قبلی فایلی روی دیسک مانده — پوشهٔ stage را پاک کن و دوباره امتحان کن.

۴. ترتیب را اعلام کن، ننویس#

ترتیبِ درست را نباید دستی نوشت. ترتیب از وابستگی‌ها درمی‌آید و وابستگی‌ها را همان موقعِ ساختنِ Stage اعلام کرده‌ایم.

def order(stages):
    """مرتب‌سازی بر پایهٔ وابستگی: هیچ مرحله‌ای قبل از سازندهٔ ورودی‌اش اجرا نمی‌شود."""
    made_by = {out: s.name for s in stages for out in s.outputs}
    done, out, todo = set(), [], list(stages)
    while todo:
        ready = [s for s in todo
                 if all(i not in made_by or made_by[i] in done for i in s.inputs)]
        if not ready:
            raise ValueError("حلقه در وابستگی‌ها: " + ", ".join(s.name for s in todo))
        ready.sort(key=lambda s: s.name)
        for s in ready:
            out.append(s)
            done.add(s.name)
            todo.remove(s)
    return out


print("ترتیبِ وابستگی    :", [s.name for s in order(PIPELINE)])
ترتیبِ وابستگی    : ['dedup', 'typed', 'normalized', 'valid', 'features']

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

ready.sort هم بی‌دلیل نیست: بدونِ آن، ترتیبِ بینِ مرحله‌های هم‌رتبه به ترتیبِ ورودی وابسته می‌شود و دو اجرا با دو ترتیبِ متفاوت، دو سیاههٔ متفاوت می‌سازند. بازتولیدپذیری از همین جزئیاتِ کوچک شروع می‌شود.

۵. اجرای مجدد: فقط چیزی که کهنه شده#

def run(stages):
    state = json.loads(STATE.read_text(encoding="utf-8")) if STATE.exists() else {}
    ran, skipped = [], []
    for s in order(stages):
        if state.get(s.name) == s.key() and all(p.exists() for p in s.outputs):
            skipped.append(s.name)
            continue
        t0 = time.perf_counter()
        n_in, n_out = s.fn(s.inputs, s.outputs)
        record = {"stage": s.name, "key": s.key(),
                  "inputs": [p.name for p in s.inputs],
                  "outputs": [p.name for p in s.outputs],
                  "rows_in": n_in, "rows_out": n_out,
                  "seconds": round(time.perf_counter() - t0, 3)}
        with open(LOG, "a", encoding="utf-8") as fh:
            fh.write(json.dumps(record, ensure_ascii=False) + "\n")
        state[s.name] = s.key()
        ran.append(s.name)
    STATE.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
    return ran, skipped


ran, skipped = run(PIPELINE)
print("اجرای اول — اجرا:", len(ran), "· رد:", len(skipped), "·", ran)
ran, skipped = run(PIPELINE)
print("اجرای دوم — اجرا:", len(ran), "· رد:", len(skipped), "·", ran)
اجرای اول — اجرا: 5 · رد: 0 · ['dedup', 'typed', 'normalized', 'valid', 'features']
اجرای دوم — اجرا: 0 · رد: 5 · []

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

چک کن: فایلِ state.json را باز کن. برای هر مرحله یک رشتهٔ دوازده‌حرفی می‌بینی: همان key. راه‌انداز فقط این را با مقدارِ الان مقایسه می‌کند. اگر اجرای دومت هم پنج مرحله اجرا کرد، تقریباً همیشه یک دلیل دارد: state.json نوشته نشده — مثلاً چون پوشهٔ کار در اجرای قبلی پاک شده است.

حالا ستونی به features اضافه می‌کنیم.

def features(inputs, outputs):
    rows = read_csv(inputs[0])
    out = [{"ticket_id": r["ticket_id"], "customer_id": r["customer_id"],
            "words": len(r["text"].split()), "chars": len(r["text"]),
            "channel": r["channel"], "category": r["category"],
            "escalated": r["escalated"]}
           for r in rows]
    write_csv(outputs[0], out, ["ticket_id", "customer_id", "words", "chars",
                                "channel", "category", "escalated"])
    return len(rows), len(out)


BY_NAME["features"].fn = features
ran, skipped = run(PIPELINE)
print("تغییرِ features:", ran, "· رد شد:", skipped)
تغییرِ features: ['features'] · رد شد: ['dedup', 'typed', 'normalized', 'valid']

یک مرحله از پنج. چون کدِ features عوض شد، key اش عوض شد؛ و چون هیچ‌کدام از بالادستی‌ها دست نخورده بودند، دست نخوردند.

حالا سراغِ وسطِ مسیر برویم: به valid یک شرطِ تازه اضافه می‌کنیم که امتیازهای ناممکن را دور بریزد.

def valid(inputs, outputs):
    rows = read_csv(inputs[0])
    out = [r for r in rows
           if r["text"] and int(r["handling_minutes"]) > 0
           and r["created_at"] < "2032-01-01"
           and r["rating"] not in ("0", "9")]
    write_csv(outputs[0], out, RAW_FIELDS)
    return len(rows), len(out)


BY_NAME["valid"].fn = valid
before = file_hash(S / "valid.csv")[:12]
ran, skipped = run(PIPELINE)
print("تغییرِ valid   :", ran, "· رد شد:", skipped)
print("اثرِ انگشتِ valid.csv:", before, "→", file_hash(S / "valid.csv")[:12])
تغییرِ valid   : ['valid', 'features'] · رد شد: ['dedup', 'typed', 'normalized']
اثرِ انگشتِ valid.csv: b78bff9d6f53 → 2ea05e333de4

دو مرحله: خودش و پایین‌دستی‌اش. تغییر به‌درستی موج زد و به features رسید، ولی از سه مرحلهٔ بالادستی رد نشد. این همان «چه چیزی باید دوباره اجرا شود» است که هیچ اسکریپتی جوابش را ندارد.

۶. وقتی کد عوض می‌شود ولی خروجی نه#

حالا valid را کاملاً بازنویسی می‌کنیم — همان شرط‌ها، این بار با یک حلقه به‌جای یک list comprehension.

def valid(inputs, outputs):
    rows = read_csv(inputs[0])
    ok = []
    for r in rows:                                  # همان شرط‌ها، فقط با حلقه
        if not r["text"] or r["rating"] in ("0", "9"):
            continue
        if int(r["handling_minutes"]) <= 0 or r["created_at"] >= "2032-01-01":
            continue
        ok.append(r)
    write_csv(outputs[0], ok, RAW_FIELDS)
    return len(rows), len(ok)


BY_NAME["valid"].fn = valid
before = file_hash(S / "valid.csv")[:12]
ran, skipped = run(PIPELINE)
print("بازنویسیِ valid:", ran, "· رد شد:", skipped)
print("اثرِ انگشتِ valid.csv:", before, "→", file_hash(S / "valid.csv")[:12])
بازنویسیِ valid: ['valid'] · رد شد: ['dedup', 'typed', 'normalized', 'features']
اثرِ انگشتِ valid.csv: 2ea05e333de4 → 2ea05e333de4

valid دوباره اجرا شد — کدش عوض شده بود — ولی features اجرا نشد.

دلیلش در خطِ دوم است: اثرِ انگشتِ valid.csv عوض نشده. بازنویسی خروجی را تغییر نداد، پس مرحلهٔ پایین‌دستی هیچ کارِ تازه‌ای ندارد.

و این تفاوتِ «اثرِ انگشتِ محتوا» با «زمانِ آخرین تغییرِ فایل» است. ابزارهای قدیمی‌ترِ ساخت به زمان نگاه می‌کنند؛ برای آن‌ها فایلی که دوباره نوشته شده حتماً تازه است، حتی اگر بایت‌هایش یکی باشد. آن‌ها اینجا features را هم دوباره اجرا می‌کردند.

📏 اندازه بگیر: با چه چیزی مقایسه شد؟ با همان مسیر، بدونِ حافظه — پنج مرحله در برابرِ یک مرحله. روی کدام داده؟ همان ۱۲۴۰ ردیف در هر پنج آزمایش. با چند seed؟ این فصل مدل ندارد، پس seed معنا ندارد؛ ولی معادلِ سخت‌گیرترش را داریم: اجرای دوباره باید عیناً همان اثرِ انگشت را بدهد — و در خطِ آخر همین را دیدی.

۷. ثبتِ منشأ — قاعدهٔ سختِ این ترم#

هر بار که مرحله‌ای اجرا شد، یک ردیف در provenance.jsonl نوشته شد. حالا از آن سیاهه بپرسیم داده در مسیر چه کشیده است.

last = {}
for line in LOG.read_text(encoding="utf-8").strip().split("\n"):
    r = json.loads(line)
    last[r["stage"]] = r
print(f"{'مرحله':<12}{'ورودی':>7}{'خروجی':>7}{'افت':>6}   از")
for s in order(PIPELINE):
    r = last[s.name]
    print(f"{s.name:<12}{r['rows_in']:>7}{r['rows_out']:>7}"
          f"{r['rows_in'] - r['rows_out']:>6}   {', '.join(r['inputs'])}")
مرحله         ورودی  خروجی   افت   از
dedup          1240   1200    40   tickets.csv
typed          1200   1200     0   dedup.csv
normalized     1200   1200     0   typed.csv
valid          1200    934   266   normalized.csv
features        934    934     0   valid.csv

۱۲۴۰ ردیف وارد شد، ۹۳۴ ردیف بیرون آمد. یک‌چهارمِ داده در راه گم شد و حالا دقیقاً می‌دانیم کجا.

چهل تا تکراری بودند و ۲۶۶ تا از فیلترِ valid رد نشدند. بدونِ این جدول، فقط عددِ آخر را می‌دیدی و «۹۳۴ تیکت» به‌نظر یک واقعیت می‌آمد، نه نتیجهٔ چهار تصمیم.

اینجاست که قاعدهٔ این ترم معنا پیدا می‌کند: هیچ تبدیلی روی داده بدونِ ثبتِ منشأ انجام نمی‌شود. سیاههٔ ما هنوز ساده است — نامِ مرحله، فایل‌ها، شمارِ ردیف، و key. ولی همین کافی است که برای هر ستونِ features.csv بگویی کدام مرحله ساختش و از چند ردیف.

🤖 از دستیارت بپرس: «تفاوتِ ‹اثرِ انگشتِ محتوا› و ‹زمانِ آخرین تغییرِ فایل› برای تشخیصِ کهنگی چیست و هرکدام کجا شکست می‌خورند؟» بعد این را هم بپرس: «اگر مرحله‌ای به ساعتِ سیستم یا به یک عددِ تصادفی وابسته باشد، خروجی‌اش هر بار عوض می‌شود؛ راه‌اندازِ من چه رفتاری نشان می‌دهد؟» — جوابش تمرینِ ستاره‌دارِ همین فصل است.

۸. چه چیزی این را از یک اسکریپت جدا کرد#

سه چیز، و هیچ‌کدام به کتابخانهٔ خاصی نیاز نداشت:

  • ۱) هر مرحله ورودی و خروجی‌اش را اعلام می‌کند. بدونِ این، نه ترتیب قابلِ محاسبه است و نه کهنگی.
  • ۲) تازگی از محتوا می‌آید، نه از زمان و نه از حافظهٔ آدم.
  • ۳) هر اجرا رد می‌گذارد. سیاهه بخشی از خروجی است، نه یک لاگِ اضافی.

و صادقانه: راه‌اندازِ ما هنوز خیلی چیزها ندارد — موازی‌سازی، اجرای روی چند ماشین، از سرگیری بعد از قطعی، رابطِ گرافیکی. ابزارهایی مثلِ Airflow و Dagster و Prefect دقیقاً همین‌ها را اضافه می‌کنند. ولی همه‌شان روی همین سه ایده ساخته شده‌اند، و کسی که این سه را خودش نوشته باشد، هر کدامشان را در یک بعدازظهر یاد می‌گیرد.

و سؤالِ درست این نیست که «کدام ابزار بهتر است»، این است که «کِی این طراحیِ تک‌ماشینه و درون‌حافظه‌ای می‌شکند». سه علامتِ قابلِ مشاهده هست و هر سه‌شان را بدونِ حدس می‌بینی: ۱) دادهٔ یک مرحله در حافظه جا نمی‌شودMemoryError می‌گیری یا ماشین شروع به swap می‌کند و زمانِ اجرا ناگهان ده‌ها برابر می‌شود. ۲) یک مرحله از عمرِ نشست بلندتر می‌شود — در Colab یعنی runtime وسطِ کار قطع می‌شود و تو هیچ خروجیِ نیمه‌کاره‌ای نداری. ۳) دو نفر یا دو زمان‌بند هم‌زمان روی یک STATE می‌نویسند — آن‌وقت فایلِ وضعیت خراب می‌شود یا یکی کارِ دیگری را دوباره انجام می‌دهد. تا وقتی هیچ‌کدام از این سه را ندیده‌ای، آوردنِ یک زمان‌بندِ توزیع‌شده فقط پیچیدگیِ اضافه است.

t0 = time.perf_counter()
STATE.unlink()                       # «همه‌چیز را از نو» — همان کاری که اسکریپت هر بار می‌کند
run(PIPELINE)
full = time.perf_counter() - t0
t0 = time.perf_counter()
run(PIPELINE)
again = time.perf_counter() - t0
print(f"اجرای کامل : {full:.2f} ثانیه")
print(f"اجرای مجدد : {again:.3f} ثانیه")
print("نسبت       :", round(full / again))
اجرای کامل : 0.06 ثانیه
اجرای مجدد : 0.004 ثانیه
نسبت       : 14

عددها روی ماشینِ تو فرق می‌کنند و مهم هم نیستند؛ نسبت مهم است. روی ۱۲۴۰ ردیف این صرفه‌جویی مسخره است. روی داده‌ای که هر مرحله‌اش چند دقیقه طول بکشد، همین نسبت تفاوتِ «روزی سه بار آزمایش می‌کنم» و «روزی یک بار» است.

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

کلمه تلفظ به حروف فارسی یعنی چه
pipeline پایپ‌لاین زنجیرهٔ مرحله‌هایی که داده را از منبع به خروجیِ نهایی می‌برد
stage استیج یک واحدِ کار با ورودی و خروجیِ اعلام‌شده
idempotent آیدم‌پوتنت کاری که اجرای دوباره‌اش وضعیت را عوض نمی‌کند
topological order توپولوژیکال اوردر ترتیبی که از وابستگی‌ها ساخته می‌شود، نه از ترتیبِ نوشتن
content hash کانتنت هش اثرِ انگشتی که فقط از بایت‌های محتوا ساخته می‌شود
staleness استیل‌نس کهنه بودنِ خروجی نسبت به ورودی یا کد
provenance پرونانس سیاههٔ اینکه هر داده از کجا و با چه مرحله‌ای آمده

تمرین‌ها

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

در فصل بعد#

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

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

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