بنیاد — الگوریتم، ساختار داده و پیچیدگی، با معیار

فصل ۸ از ۱۰

پیشرفت ترم
۰٪

ترم ۱ · «سریع» یعنی چه: اول اندازه بگیر، بعد پیش‌بینی کن

برازش کن، پیش‌بینی کن، اجرا کن، خطا را گزارش کن

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

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

این فصل امضای کلِ دوره است. پنج ترمِ بعدی فقط همین پروتکل را روی ساختارها و الگوریتم‌های مختلف اجرا می‌کنند.

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

اول روی یک تابعِ تمیز اجرایش می‌کنیم و خطای 0.025 درصد می‌گیریم. بعد عمداً می‌شکنیمش: یک تابع، یک پروتکل، دو بازهٔ nخطای 33.2 درصد و خطای 5.9 درصد. اولی رد می‌شود و دومی قبول، و تنها تفاوتشان این است که جدول را کجا گرفته‌ایم.

دو خط‌کشِ فنری روی یک منحنی، که یکی از روی بازهٔ کوتاه تنظیم شده و یکی از روی بازهٔ بلند

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

  • از یک جدولِ اندازه‌گیری، ثابتِ برازش را دربیاوری
  • اندازهٔ بعدی را پیش‌بینی کنی و قبل از اجرا بنویسی چه خطایی را قبول داری
  • خطای نسبی را حساب کنی و نتیجه را «قبول» یا «رد» اعلام کنی
  • شکستِ یک پیش‌بینی را به‌عنوان یک نتیجه گزارش کنی، نه یک آبروریزی

قبل از شروع#

از فصلِ ۷: Big-O به‌عنوان یک پیش‌بینی، و اینکه فقط دربارهٔ nهای بزرگ حرف می‌زند.

از فصلِ ۵: جدولِ دوبرابری و ستونِ نسبت. از فصلِ ۴: شمارندهٔ عملیات.

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

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

۱. برازش یعنی پیدا کردنِ یک عدد#

ستونِ نسبت به تو شکل را می‌دهد: نزدیکِ 4 یعنی . ولی شکل به‌تنهایی پیش‌بینی نمی‌سازد؛ برای پیش‌بینی به یک عددِ دیگر هم احتیاج داری: ضریبی که آن شکل را روی دادهٔ تو می‌نشاند.

آن ضریب را از تقسیمِ ساده درمی‌آوری: c برابر است با اندازه‌گیری تقسیم بر شکل. اگر شکل درست انتخاب شده باشد، این عدد در همهٔ سطرها تقریباً یکی درمی‌آید — و همان، آزمونِ درستیِ انتخابِ توست.

def all_pairs(values):
    ops = 0
    for i in range(len(values)):
        for j in range(i + 1, len(values)):
            ops += 1
    return ops


def square(n):
    return n * n


LADDER = [250, 500, 1_000, 2_000]
measured = {n: all_pairs(list(range(n))) for n in LADDER}
table([(n, measured[n]) for n in LADDER], "عملیات", ",d")
print()
print(f"{'n':>10}{'عملیات ÷ n²':>18}")
for n in LADDER:
    print(f"{n:>10,}{measured[n] / square(n):>18.5f}")
         n            عملیات     نسبت به سطر قبل
       250            31,125                   —
       500           124,750                4.01
     1,000           499,500                4.00
     2,000         1,999,000                4.00

         n       عملیات ÷ n²
       250           0.49800
       500           0.49900
     1,000           0.49950
     2,000           0.49975

ستونِ دوم چهار بار تقریباً همان عدد را می‌دهد: نزدیکِ 0.5.

این یعنی انتخابِ شکل درست بوده. اگر شکل را غلط انتخاب کرده بودی — مثلاً n به‌جای — این ستون به‌جای ثابت ماندن، خودش با n بالا می‌رفت و همان‌جا لو می‌رفت.

چک کن: square را با تابعی عوض کن که فقط n برمی‌گرداند و همان ستون را دوباره بگیر. عددها باید 124.5، 249.5، 499.5 و 999.5 شوند — یعنی هر بار تقریباً دو برابر، نه ثابت. همین «ثابت نماندن» تنها آزمونی است که می‌گوید شکل را غلط انتخاب کرده‌ای، و قبل از هر پیش‌بینی‌ای باید انجامش بدهی.

🌱 ریشه‌اش کجاست: اگر عملیات را روی محورِ عمودی و را روی محورِ افقی بگذاری، این چهار نقطه روی یک خطِ راستِ گذرنده از مبدأ می‌نشینند و آن 0.5 دقیقاً شیبِ همان خط است. معنای شیب به‌عنوانِ نرخِ تغییر در ریشه ترمِ ۴ فصل ۴ ساخته می‌شود و شکلِ y = mx + b در ریشه ترمِ ۴ فصل ۳. کارِ ما اینجا همان است، با b برابرِ صفر.

۲. پیش‌بینی، با معیارِ پذیرشِ نوشته‌شده#

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

اگر معیار را بعد از دیدنِ نتیجه بنویسی، همیشه قبول می‌شوی — و آن دیگر آزمون نیست.

🤔 اول حدس بزن: ثابتِ برازش نزدیکِ 0.5 است و اندازهٔ بعدی 4,000. بنویس چند عملیات پیش‌بینی می‌کنی، و بنویس خطایت را زیرِ چند درصد انتظار داری.

LAST = LADDER[-1]
NEXT = 2 * LAST
TOLERANCE = 25.0                 # درصد — معیارِ پذیرش، پیش از اجرا نوشته شد

c = measured[LAST] / square(LAST)
predicted = c * square(NEXT)
print(f"ثابتِ برازش از n = {LAST:,} : c = {c:.5f}")
print(f"پیش‌بینی برای n = {NEXT:,}   : {predicted:,.0f} عملیات")
print(f"معیارِ پذیرش              : خطای کمتر از {TOLERANCE:.0f} درصد")

actual = all_pairs(list(range(NEXT)))
error = 100 * abs(predicted - actual) / actual
print(f"\nاندازه‌گیریِ واقعی        : {actual:,} عملیات")
print(f"خطای نسبی                : {error:.3f} درصد")
print("نتیجه                    :", "قبول" if error < TOLERANCE else "رد")
ثابتِ برازش از n = 2,000 : c = 0.49975
پیش‌بینی برای n = 4,000   : 7,996,000 عملیات
معیارِ پذیرش              : خطای کمتر از 25 درصد

اندازه‌گیریِ واقعی        : 7,998,000 عملیات
خطای نسبی                : 0.025 درصد
نتیجه                    : قبول

خطای بیست‌وپنج هزارم درصد. و توجه کن که صفر نشد — چون فرمولِ واقعی n(n-1)/2 است و ما با n²/2 تقریبش زدیم. آن ‎-n/2 جا افتاده، و در n = 4000 سهمش دقیقاً همان دو هزار عمل است که کم آورده‌ایم.

این پروتکل چهار قدم دارد و از این به بعد هر جا ادعای پیچیدگی می‌کنیم، همین چهار قدم اجرا می‌شوند:

  1. جدولِ دوبرابری بگیر و از ستونِ نسبت شکل را اعلام کن.
  2. ثابتِ برازش را از بزرگ‌ترین اندازهٔ اندازه‌گیری‌شده دربیاور.
  3. معیارِ پذیرش را بنویس، بعد اندازهٔ بعدی را پیش‌بینی کن.
  4. اجرا کن، خطای نسبی را حساب کن، و «قبول» یا «رد» را اعلام کن.

قدمِ دومْ ریزه‌کاری دارد: ثابت را از بزرگ‌ترین اندازه بردار، نه از میانگینِ همه. بزرگ‌ترین اندازه به nی که می‌خواهی پیش‌بینی کنی نزدیک‌تر است، و در بخشِ بعد می‌بینی چرا این نزدیکی همه‌چیز است.

۳. یک پیش‌بینی که رد می‌شود#

حالا تابعی که دو بخشِ کار دارد: یک حلقهٔ تودرتو و یک کارِ خطیِ سنگین.

K = 100


def mixed_work(values):
    """یک حلقهٔ تودرتو، به‌علاوهٔ یک کارِ خطیِ سنگین."""
    ops = 0
    n = len(values)
    for i in range(n):
        for j in range(i + 1, n):
            ops += 1
    for _ in range(K * n):
        ops += 1
    return ops


SMALL = [25, 50, 100, 200]
small_measured = {n: mixed_work(list(range(n))) for n in SMALL}
table([(n, small_measured[n]) for n in SMALL], "عملیات", ",d")

c_small = small_measured[200] / square(200)
predicted_small = c_small * square(400)
actual_small = mixed_work(list(range(400)))
error_small = 100 * abs(predicted_small - actual_small) / actual_small
print(f"\nبرازش روی n = 200 → c = {c_small:.5f}")
print(f"پیش‌بینی برای n = 400: {predicted_small:,.0f}")
print(f"اندازه‌گیریِ واقعی    : {actual_small:,}")
print(f"خطای نسبی            : {error_small:.1f} درصد  →",
      "قبول" if error_small < TOLERANCE else "رد")
         n            عملیات     نسبت به سطر قبل
        25             2,800                   —
        50             6,225                2.22
       100            14,950                2.40
       200            39,900                2.67

برازش روی n = 200 → c = 0.99750
پیش‌بینی برای n = 400: 159,600
اندازه‌گیریِ واقعی    : 119,800
خطای نسبی            : 33.2 درصد  → رد

رد. و این بهترین لحظهٔ این فصل است.

اول به ستونِ نسبت نگاه کن: 2.22، 2.40، 2.67. این عددها نه نزدیکِ 2 هستند و نه نزدیکِ 4؛ بینِ دو خانواده‌اند و بالا می‌روند. دقیقاً همان چیزی که یک تابعِ دوجمله‌ای در ناحیهٔ گذارش نشان می‌دهد. اگر جدول را با دقت خوانده بودیم، همان‌جا می‌فهمیدیم که برای برازش هنوز خیلی زود است.

و اشتباهی که بعدش کردیم صریح است: ما شکلِ را روی داده‌ای نشاندیم که هنوز بر آن غالب نشده بود. در n = 200، جملهٔ خطی 20,000 است و جملهٔ تودرتو 19,900 — یعنی نصفِ کار مالِ جمله‌ای است که در فرمولِ پیش‌بینیِ ما اصلاً نبود.

⚠️ مواظب باش: پیش‌بینیِ ما بیشتر از واقعیت درآمد، نه کمتر. این ضدِ شهود است — انتظار داری با جا انداختنِ یک جمله، کم‌برآورد کنی. ولی ثابتِ c را از همان دادهٔ آلوده گرفتیم، پس c بزرگ‌تر از حقش درآمد (0.9975 به‌جای چیزی نزدیکِ 0.5) و بعد آن ثابتِ بزرگ‌شده در ضرب شد. درسش این است: وقتی شکل را غلط انتخاب کنی، حتی جهتِ خطا هم قابلِ حدس نیست.

۴. همان تابع، همان پروتکل، بازهٔ درست#

هیچ چیزی از کد را عوض نمی‌کنیم. فقط جدول را در بازهٔ بزرگ‌تری می‌گیریم.

BIG = [200, 400, 800, 1_600]
big_measured = {n: mixed_work(list(range(n))) for n in BIG}
table([(n, big_measured[n]) for n in BIG], "عملیات", ",d")

c_big = big_measured[1_600] / square(1_600)
predicted_big = c_big * square(3_200)
actual_big = mixed_work(list(range(3_200)))
error_big = 100 * abs(predicted_big - actual_big) / actual_big
print(f"\nبرازش روی n = 1,600 → c = {c_big:.5f}")
print(f"پیش‌بینی برای n = 3,200: {predicted_big:,.0f}")
print(f"اندازه‌گیریِ واقعی      : {actual_big:,}")
print(f"خطای نسبی              : {error_big:.1f} درصد  →",
      "قبول" if error_big < TOLERANCE else "رد")
         n            عملیات     نسبت به سطر قبل
       200            39,900                   —
       400           119,800                3.00
       800           399,600                3.34
     1,600         1,439,200                3.60

برازش روی n = 1,600 → c = 0.56219
پیش‌بینی برای n = 3,200: 5,756,800
اندازه‌گیریِ واقعی      : 5,438,400
خطای نسبی              : 5.9 درصد  → قبول

33.2 درصد به 5.9 درصد، بدونِ یک خط تغییر در کد.

ستونِ نسبت هم داستان را می‌گوید: 3.00، 3.34، 3.60 — هنوز به 4 نرسیده ولی در راهش است. و ثابتِ برازش از 0.9975 به 0.5622 آمده، یعنی به 0.5ِ واقعی نزدیک‌تر شده.

این کلِ معنای «nِ خودت را بگو» است. یک ادعای پیچیدگی بدونِ بازهٔ n نه درست است و نه غلط؛ بی‌معناست.

📏 اندازه بگیر: چه چیزی با چه چیزی مقایسه شد؟ یک پروتکلِ ثابت روی یک تابعِ ثابت، در دو بازهٔ متفاوتِ n. در کدام بازهٔ n؟ برازش روی ۲۵ تا ۲۰۰ و پیش‌بینی برای ۴۰۰؛ بعد برازش روی ۲۰۰ تا ۱٬۶۰۰ و پیش‌بینی برای ۳٬۲۰۰. با چند تکرار، و کمینه یا میانگین؟ شمارش است، پس یک اجرا و بدونِ خلاصه — و به همین دلیل عددِ 33.2 و 5.9 روی ماشینِ تو هم دقیقاً همین‌ها هستند.

۵. همان پروتکل، این بار با ساعت#

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

TIME_LADDER = [500, 1_000, 2_000]
data = {n: list(range(n)) for n in TIME_LADDER + [4_000]}
times = {n: 1000 * clock(lambda values=data[n]: all_pairs(values), repeat=3)
         for n in TIME_LADDER}
table([(n, times[n]) for n in TIME_LADDER])

c_time = times[2_000] / square(2_000)
predicted_time = c_time * square(4_000)
actual_time = 1000 * clock(lambda: all_pairs(data[4_000]), repeat=3)
error_time = 100 * abs(predicted_time - actual_time) / actual_time
print(f"\nپیش‌بینی برای n = 4,000: {predicted_time:,.1f} ms")
print(f"اندازه‌گیریِ واقعی      : {actual_time:,.1f} ms")
print(f"خطای نسبی              : {error_time:.1f} درصد  →",
      "قبول" if error_time < TOLERANCE else "رد")
         n         زمان (ms)     نسبت به سطر قبل
       500               3.4                   —
     1,000              15.4                4.47
     2,000              57.2                3.72

پیش‌بینی برای n = 4,000: 228.8 ms
اندازه‌گیریِ واقعی      : 233.3 ms
خطای نسبی              : 1.9 درصد  →  قبول

پیش‌بینیِ زمان هم قبول شد، ولی عددِ خطایش را نمی‌شود مثلِ شمارش جدی گرفت.

دلیلش را از فصلِ ۳ می‌دانی: پراکندگیِ خودِ اندازه‌گیری با این پروتکل حدودِ دو درصد است. خطای 1.9 درصد دقیقاً در همان بازه است، یعنی از نویز قابلِ تشخیص نیست. اگر عدد 1.9 یا 0.4 یا 3.1 درآید، هر سه یک معنا دارند: «پیش‌بینی خراب نبود».

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

🔧 اگر کار نکرد: طبیعی است که بخواهی نتیجهٔ اندازهٔ بعدی را از همان دیکشنریِ اندازه‌گیری‌ها بخوانی. همان‌جا پروتکل به تو یادآوری می‌کند که هنوز اجرایش نکرده‌ای:

try:
    print(measured[16_000])
except KeyError as err:
    print(f"{type(err).__name__}: {err}")
KeyError: 16000

KeyError: 16000 یعنی «چنین اندازه‌ای در اندازه‌گیری‌های تو نیست». و این دقیقاً همان چیزی است که پیش‌بینی را پیش‌بینی می‌کند: عددِ 16,000 هنوز وجود ندارد و تا وقتی اجرایش نکنی، هر چیزی که درباره‌اش می‌گویی یک ادعاست. پروتکل می‌گوید ادعا را بنویس، بعد اجرا کن، بعد خطا را گزارش کن.

🤖 از دستیارت بپرس: «چرا ثابتِ برازش را باید از بزرگ‌ترین اندازهٔ اندازه‌گیری‌شده گرفت و نه از میانگینِ همهٔ سطرها؟» بعد این را هم بپرس: «اگر به‌جای دو برابر، بخواهم ده برابرِ بزرگ‌ترین اندازهٔ اندازه‌گیری‌شده را پیش‌بینی کنم، انتظار دارم خطا چه بلایی سرش بیاید؟» — جوابِ درست این است که خطا بزرگ‌تر می‌شود، و دلیلش همان چیزی است که در بخشِ ۳ دیدی: هرچه از بازهٔ اندازه‌گیری دورتر بروی، سهمِ جمله‌هایی که در فرمولت نیستند بیشتر عوض می‌شود.

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

کلمه تلفظ به حروف فارسی یعنی چه
fit فیت پیدا کردنِ ضریبی که شکلِ رشد را روی دادهٔ اندازه‌گیری‌شده می‌نشاند
relative error رلتیو ارور فاصلهٔ پیش‌بینی تا واقعیت، بر حسبِ درصدِ واقعیت
acceptance criterion اکسپتنس کرایتریون حدِ خطایی که پیش از اجرا نوشته می‌شود
interpolation اینترپولیشن پیش‌بینی داخلِ بازه‌ای که اندازه گرفته‌ای
extrapolation اکستراپولیشن پیش‌بینی بیرونِ آن بازه — همیشه پرریسک‌تر

تمرین‌ها

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

در فصل بعد#

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

دو الگوریتم برای یک مسئلهٔ واحد می‌نویسیم: یکی O(n²) و دیگری با مرتبهٔ خیلی بهتر ولی با یک سربارِ راه‌اندازیِ سنگین. در شمارشِ عملیات، نقطهٔ تقاطعشان دقیقاً n = 449 است و این عدد روی هر ماشینی همین است. روی ساعت، نقطهٔ تقاطع n = 550 درآمد — و آن عدد مالِ همین ماشین است. تفاوتِ این دو عدد، کلِ چیزی است که Big-O به تو نمی‌گوید.

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

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