در این فصل چه یاد میگیری#
فصلِ قبل نقطهٔ داغ را پیدا کرد. این فصل سه ابزارِ سریعکردن را امتحان میکند و هر سه را با عدد میسنجد — چون دو تا از آنها در جای اشتباه هیچ سودی ندارند.
بزرگترین عددِ این فصل: پیدا کردنِ نزدیکترین همسایه با حلقهٔ پایتونی ششصد میلیثانیه طول میکشد و با یک ضربِ ماتریسی زیرِ نیم میلیثانیه — بیش از هزار برابر، با جوابِ مو به مو یکسان. و کوچکترین عدد: یک cache که در جای درست ۱٫۴۷ برابر سود میدهد، روی ورودیِ بدون تکرار ۰٫۹۶ برابر میشود — یعنی کندتر.

آخر این فصل میتوانی:
- یک حلقهٔ عددی را برداری کنی و ثابت کنی جواب عوض نشده
- کپیِ بیجا را از کپیِ بیضرر تشخیص بدهی، با عدد
- بگویی نرخِ اصابتِ
cacheچطور سقفِ بهبودت را تعیین میکند - تصمیم بگیری کِی
cacheنگذاری
قبل از شروع#
از فصلِ ۶: «سقفِ بهبودِ تو، سهمِ همان بخش از کلِ زمان است». این فصل همان قاعده را یک لایه پایینتر میبرد: سقفِ بهبودِ cache، نرخِ اصابتش است.
از سرنخ ترمِ ۲: numpy، ndarray، shape و کارِ برداری. اینجا فرض میکنیم بلدی و فقط اندازه میگیریم.
| اجرا | زمانِ تقریبی |
|---|---|
| CPU (پیشفرضِ Colab) | حدودِ دو دقیقه |
📓 نوتبوک: نوتبوک این فصل را در Colab باز کن — همهٔ کدهای این فصل آماده و بهترتیب داخلش هست.
۱. برداریسازی#
کاری که میسنجیم واقعی است: برای هر تیکت، شبیهترین تیکتِ دیگر را پیدا کن. همان کاری که برای پیدا کردنِ تیکتهای تکراری لازم داری.
import re
import time
import numpy as np
from sklearn.feature_extraction.text import CountVectorizer
def normalise(text):
text = text.replace("ي", "ی").replace("ك", "ک")
return re.sub(r"\s+", " ", text).strip()
def clock(fn, repeat=3):
best = float("inf")
for _ in range(repeat):
start = time.perf_counter()
result = fn()
best = min(best, time.perf_counter() - start)
return best, result
rows = TICKETS[:100]
texts = [normalise(r["text"]) for r in rows]
M = CountVectorizer().fit_transform(texts).toarray().astype(float)
print("ماتریسِ سند-واژه:", M.shape, "=", M.shape[0] * M.shape[1], "خانه")
ماتریسِ سند-واژه: (100, 176) = 17600 خانه
def nearest_loops(M):
n = len(M)
out = []
for i in range(n):
best_score, best_j = -1.0, -1
for j in range(n):
if i == j:
continue
dot = norm_i = norm_j = 0.0
for a, b in zip(M[i], M[j]):
dot += a * b
norm_i += a * a
norm_j += b * b
score = dot / ((norm_i ** 0.5) * (norm_j ** 0.5))
if score > best_score:
best_score, best_j = score, j
out.append(best_j)
return out
loop_time, loop_result = clock(lambda: nearest_loops(M), repeat=1)
print(f"حلقهٔ پایتونی: {loop_time * 1000:.0f} میلیثانیه")
print("چند نمونه از نزدیکترین همسایه:", loop_result[:5])
حلقهٔ پایتونی: 617 میلیثانیه
چند نمونه از نزدیکترین همسایه: [79, 92, 24, 51, 28]
سه حلقهٔ تودرتو: صد سند در صد سند در ۱۷۶ ستون. حالا همان کار، بدونِ اینکه حتی یک حلقه بنویسیم:
def nearest_vectorised(M):
norms = np.sqrt((M * M).sum(axis=1))
similarity = (M @ M.T) / np.outer(norms, norms)
np.fill_diagonal(similarity, -1.0)
return similarity.argmax(axis=1).tolist()
vec_time, vec_result = clock(lambda: nearest_vectorised(M))
print(f"نسخهٔ برداری : {vec_time * 1000:.1f} میلیثانیه")
print(f"بهبود : {loop_time / vec_time:.0f} برابر")
print("همان جواب؟", vec_result == loop_result)
نسخهٔ برداری : 0.4 میلیثانیه
بهبود : 1699 برابر
همان جواب؟ True
بیش از هزار برابر، با جوابِ کاملاً یکسان.
و آن True بخشِ اجباریِ ماجراست، نه تزیین. برداریسازی یعنی بازنویسیِ منطق با شکلِ متفاوت، و هر بازنویسی میتواند رفتار را عوض کند. قاعده: هر بهینهسازی باید کنارِ خودش یک مقایسهٔ خروجی داشته باشد.
چرا اینقدر تفاوت؟ حلقهٔ پایتونی برای هر ضرب یک شیءِ float میسازد و برمیگرداند، و مفسر برای هر گام کارِ زیادی میکند. M @ M.T همان ضربها را داخلِ کدِ کامپایلشدهٔ numpy انجام میدهد — بدونِ ساختنِ شیء، روی حافظهٔ پیوسته، و با دستورهایی که چند عدد را همزمان حساب میکنند.
پس قاعدهٔ عملی: هر جا حلقهای روی اعداد نوشتی، اول بپرس آیا numpy همان را در یک عبارت انجام میدهد. برای متن و شیء و منطقِ شرطی معمولاً نه؛ برای عدد تقریباً همیشه بله.
۲. کپیای که فکر میکنی گران است#
مسیرِ فصلِ قبل سه بار داده را کپی میکرد. حالا بسنجیمش — و آماده باش که جواب کمهیجان باشد.
def three_copies(rows):
a = [{**r, "text": normalise(r["text"])} for r in rows]
b = [{**r, "text": r["text"].lower()} for r in a]
c = [{**r, "words": r["text"].split()} for r in b]
return c
def one_pass(rows):
out = []
for r in rows:
text = normalise(r["text"]).lower()
out.append({**r, "text": text, "words": text.split()})
return out
big = [r for r in MESSY if isinstance(r["text"], str)]
print("ردیفها:", len(big))
print("دیکشنریهای ساختهشده — سهکپی:", 3 * len(big), "| یکگذر:", len(big))
print("خروجی یکی است؟", three_copies(big) == one_pass(big))
ردیفها: 782
دیکشنریهای ساختهشده — سهکپی: 2346 | یکگذر: 782
خروجی یکی است؟ True
import tracemalloc
for name, fn in [("سه کپی", three_copies), ("یک گذر", one_pass)]:
tracemalloc.start()
fn(big)
_, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
spent, _ = clock(lambda: fn(big))
print(f" {name:<8} اوجِ حافظه {peak / 1024:>7.0f} کیلوبایت زمان {spent * 1000:>6.1f} میلیثانیه")
سه کپی اوجِ حافظه 1634 کیلوبایت زمان 5.1 میلیثانیه
یک گذر اوجِ حافظه 1188 کیلوبایت زمان 4.7 میلیثانیه
سه برابر دیکشنری، ولی فقط ۱٫۴ برابر حافظه و تقریباً همان زمان.
دلیلش مهم است: {**r, ...} کپیِ سطحی است. یک دیکشنریِ تازه میسازد ولی رشتهها را کپی نمیکند — همان اشیاء را دوباره اشاره میکند. پس هزینهاش فقط اسکلتِ دیکشنری است، نه محتوا.
این یک نتیجهٔ کمرمق است و باید همانطور گزارش شود. «سه بار کپی نکن» یک توصیهٔ درستنما بود که وقتی سنجیدیمش، ۱٫۴ برابر حافظه و صفر زمان درآمد. اینطور توصیهها را نگه دار ولی بهعنوانِ عادتِ خوب، نه بهعنوانِ بهینهسازی.
۳. کپیای که واقعاً گران است#
حالا کپیِ واقعی — و این یکی مخصوصِ کارِ متنی است:
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
corpus = [normalise(r["text"]) for r in big]
labels = [r["label"] for r in big]
sparse = CountVectorizer().fit_transform(corpus)
dense = sparse.toarray()
sparse_bytes = sparse.data.nbytes + sparse.indices.nbytes + sparse.indptr.nbytes
print("شکل:", sparse.shape, "| خانههای ناصفر:", sparse.nnz,
f"({sparse.nnz / (sparse.shape[0] * sparse.shape[1]):.2%})")
print(f"حافظهٔ نسخهٔ sparse: {sparse_bytes / 1024:>8.0f} کیلوبایت")
print(f"حافظهٔ نسخهٔ dense : {dense.nbytes / 1024:>8.0f} کیلوبایت")
print(f"نسبت : {dense.nbytes / sparse_bytes:>8.0f} برابر")
شکل: (782, 596) | خانههای ناصفر: 10482 (2.25%)
حافظهٔ نسخهٔ sparse: 126 کیلوبایت
حافظهٔ نسخهٔ dense : 3641 کیلوبایت
نسبت : 29 برابر
بیستونه برابر — و ۹۸ درصدِ آن ماتریس صفر است.
هر تیکت حدودِ سیزده کلمه دارد و واژگانِ کل ۵۹۶ کلمه است، پس در هر سطر ۵۸۳ خانه صفر است. نسخهٔ sparse فقط خانههای ناصفر را نگه میدارد؛ toarray() تکتکِ آن صفرها را بهشکلِ هشت بایتِ واقعی مینویسد.
و حالا سؤالی که این کپی را «بیجا» میکند:
scores = []
for matrix in (sparse, dense):
X_tr, X_te, y_tr, y_te = train_test_split(
matrix, labels, test_size=0.25, random_state=0, stratify=labels)
model = LogisticRegression(max_iter=1000).fit(X_tr, y_tr)
scores.append(round(model.score(X_te, y_te), 4))
print("دقت با sparse:", scores[0], "| دقت با dense:", scores[1], "| یکی هستند؟", scores[0] == scores[1])
دقت با sparse: 0.8673 | دقت با dense: 0.8673 | یکی هستند؟ True
مدل هر دو را قبول میکند و جوابِ یکسان میدهد. یعنی آن .toarray() بیستونه برابر حافظه گرفت و صفر فایده داشت.
این پرتکرارترین کپیِ بیجا در کارِ متنی است، و روی همین پیکرهٔ کوچک فقط سهونیم مگابایت خرج میکند. روی یک پیکرهٔ صدهزارتایی با واژگانِ پنجاههزارکلمهای، همان یک خط چهل گیگابایت میخواهد و runtimeِ Colab را میکشد.
✅ چک کن: اگر عددِ «خانههای ناصفر» پیشِ تو خیلی بزرگتر بود، احتمالاً
normaliseرا جا انداختهای و ی و ک عربی واژگان را باد کردهاند. نسبتِ ناصفرها باید حدودِ دو درصد باشد؛ هرچه واژگان بزرگتر، این نسبت کوچکتر و سودِsparseبیشتر.
۴. cache: نرخِ اصابت سقف را تعیین میکند#
lru_cache جوابِ هر ورودی را نگه میدارد و دفعهٔ بعد بهجای محاسبه، برش میدارد. پیکرهٔ ما تیکتِ تکراری دارد، پس باید سود بدهد. چقدر؟
import functools
all_texts = [r["text"] for r in big]
unique_texts = sorted(set(all_texts))
primed = functools.lru_cache(maxsize=None)(normalise)
for t in all_texts:
primed(t)
info = primed.cache_info()
print("فراخوانیها:", len(all_texts), "| یکتا:", len(unique_texts))
print("اصابت (hit):", info.hits, "| خطا (miss):", info.misses)
print(f"نرخِ اصابت : {info.hits / len(all_texts):.2%}")
print(f"سقفِ نظریِ بهبود با این نرخ: {1 / (1 - info.hits / len(all_texts)):.2f} برابر")
فراخوانیها: 782 | یکتا: 539
اصابت (hit): 243 | خطا (miss): 539
نرخِ اصابت : 31.07%
سقفِ نظریِ بهبود با این نرخ: 1.45 برابر
قبل از هر اندازهگیریِ زمان، سقف را میدانیم: ۱٫۴۵ برابر.
حساب ساده است: اگر ۳۱ درصدِ فراخوانیها رایگان شوند، ۶۹ درصدِ کار میماند و ۱ ÷ ۰٫۶۹ میشود ۱٫۴۵. حتی اگر cache کاملاً رایگان باشد، از این بالاتر نمیرود. حالا واقعیت را ببینیم — روی یک تابعِ ارزان و یک تابعِ گران:
def heavy(text): # یک تابعِ گران: همان کار، دویست بار
out = normalise(text)
for _ in range(200):
out = normalise(out)
return out
def cold_cache_run(fn, texts):
cached = functools.lru_cache(maxsize=None)(fn)
start = time.perf_counter()
for t in texts:
cached(t)
return time.perf_counter() - start
def plain_run(fn, texts):
start = time.perf_counter()
for t in texts:
fn(t)
return time.perf_counter() - start
for name, fn in [("normalise (ارزان)", normalise), ("heavy (گران)", heavy)]:
plain = min(plain_run(fn, all_texts) for _ in range(3))
cached = min(cold_cache_run(fn, all_texts) for _ in range(3))
print(f" {name:<20} بدونِ cache {plain * 1000:>8.1f} ms | با cache {cached * 1000:>8.1f} ms"
f" | {plain / cached:>5.2f} برابر")
normalise (ارزان) بدونِ cache 3.5 ms | با cache 2.6 ms | 1.36 برابر
heavy (گران) بدونِ cache 673.5 ms | با cache 457.9 ms | 1.47 برابر
هر دو تقریباً روی همان سقفِ ۱٫۴۵ نشستند — و این تصادفی نیست: نرخِ اصابت یکی است، پس سقف یکی است.
و آن ۱٫۴۷ که یک صدم از سقف بالاتر است، سقف را نشکسته. سقف یک حسابِ دقیق روی شمارشِ اصابتهاست و ۱٫۴۷ یک اندازهگیریِ زمان با نوسانِ خودش. هر وقت یک اندازهگیری کمی از حدِ نظریاش گذشت، اول به نوسانِ اندازهگیری شک کن، نه به حساب.
به cold_cache_run دقت کن: هر بار cacheِ تازه میسازد. اگر این کار را نکنیم و همان cacheِ گرمشده را دوباره بسنجیم، عددِ صد برابری میگیریم که معنایی ندارد — چون در کارِ واقعی هر بار runtime تازه بالا میآید. این یکی از رایجترین راههای دروغ گفتن با بنچمارک است.
۵. cacheی که ضرر داد#
حالا همان تابعِ گران، روی ورودیِ بدونِ تکرار:
fresh = functools.lru_cache(maxsize=None)(heavy)
for t in unique_texts:
fresh(t)
print("روی ورودیِ کاملاً یکتا → اصابت:", fresh.cache_info().hits,
"| خطا:", fresh.cache_info().misses)
plain = min(plain_run(heavy, unique_texts) for _ in range(3))
cached = min(cold_cache_run(heavy, unique_texts) for _ in range(3))
print(f"بدونِ cache {plain * 1000:.1f} ms | با cache {cached * 1000:.1f} ms | {plain / cached:.2f} برابر")
روی ورودیِ کاملاً یکتا → اصابت: 0 | خطا: 539
بدونِ cache 466.9 ms | با cache 484.9 ms | 0.96 برابر
صفر اصابت، و ۰٫۹۶ برابر — یعنی کندتر.
cache مجانی نیست: برای هر فراخوانی باید کلید بسازد، hash بگیرد، در جدول بگردد، و بعد جوابِ تازه را ذخیره کند. وقتی هیچوقت اصابتی در کار نباشد، اینها خالص هزینهاند — بهعلاوهٔ حافظهای که هر ۵۳۹ ورودی را نگه میدارد و هرگز پس نمیدهد.
📏 اندازه بگیر: با چه چیزی مقایسه شد؟ با همان تابع بدونِ
cache، روی همان ورودیها. روی کدام داده؟ یک بار کلِ ۷۸۲ فراخوانی (با ۳۱ درصد تکرار) و یک بار فقط ۵۳۹ ورودیِ یکتا. با چندseed؟ بیربط است — ولی جایش هر اندازهگیری سه بار تکرار شده و کمینه گزارش شده، و هر بار باcacheِ سرد. بدونِ آن قیدِ آخر، این جدول کاملاً بیمعنا میشد.
۶. maxsize: حافظه در برابرِ اصابت#
bounded = functools.lru_cache(maxsize=64)(normalise)
for t in all_texts:
bounded(t)
print("با maxsize=64 → اصابت:", bounded.cache_info().hits, "| کلیدهای نگهداشته:", bounded.cache_info().currsize)
print("با maxsize=None → اصابت:", primed.cache_info().hits, "| کلیدهای نگهداشته:", primed.cache_info().currsize)
با maxsize=64 → اصابت: 47 | کلیدهای نگهداشته: 64
با maxsize=None → اصابت: 243 | کلیدهای نگهداشته: 539
سقفِ ۶۴تایی حافظه را هشت برابر کم کرد و اصابت را پنج برابر.
و این دقیقاً همان معاملهای است که باید آگاهانه انجام بدهی. maxsize=None یعنی «هرگز چیزی را دور نریز» — که در یک برنامهٔ کوتاه بیخطر است و در سرویسی که ماهها بالاست، یک نشتِ حافظهٔ کامل با کلیدهایی که هرگز دوباره نمیآیند.
🔧 اگر کار نکرد: اگر
TypeError: unhashable type: 'dict'گرفتی،lru_cacheرا روی تابعی گذاشتهای کهdictیاlistمیگیرد.cacheباید ورودی را بهعنوانِ کلید نگه دارد و کلید بایدhashableباشد. راهش این است کهcacheرا روی لایهٔ داخلیتری بگذاری که فقط رشته یا عدد میگیرد — دقیقاً مثلِ همینnormalise.
🤖 از دستیارت بپرس: «چرا
numpyروی آرایهٔ پیوسته اینقدر سریعتر از حلقهٔ پایتونی است؟» جوابِ کامل شاملِ سه چیز است: نبودِ سربارِ مفسر، دادهٔ پیوسته در حافظه، و دستورهایی که چند عدد را همزمان حساب میکنند. بعد خودت این را امتحان کن:nearest_vectorisedرا رویMبا هزار سطر بزن و ببین حافظه چه میکند. راهنمایی:M @ M.Tیک ماتریسِn×nمیسازد. برداریسازی زمان را میخرد و گاهی با حافظه پول میدهد.
واژههای تازهٔ این فصل#
| کلمه | تلفظ به حروف فارسی | یعنی چه |
|---|---|---|
| vectorisation | وکتورایزیشن | انجامِ یک عمل روی کلِ آرایه بهجای حلقه |
| sparse matrix | اسپارس ماتریکس | ماتریسی که فقط خانههای ناصفرش ذخیره میشود |
| shallow copy | شالو کپی | کپیِ ظرف، بدونِ کپیِ محتوا |
lru_cache |
الآریو کش | نگهداشتنِ جوابِ فراخوانیهای قبلی |
| hit rate | هیت ریت | سهمِ فراخوانیهایی که در cache پیدا میشوند |
maxsize |
مکسسایز | سقفِ تعدادِ کلیدهایی که cache نگه میدارد |
تمرینها
اول خودت فکر کن یا امتحان کن — بعد اینجا را باز کن.
در فصل بعد#
تا اینجا همهچیز یک کار در یک زمان بود. فصلِ بعد سراغِ همزمانی میرود — نخ، فرآیند، و async — و همانجا با تلخترین عددِ این ترم روبهرو میشویم: چهار نخ روی کارِ محاسباتی، دقیقاً همانقدر طول میکشد که یک نخ. دلیلش اسم دارد و GIL نامیده میشود.
به آخر این فصل رسیدی!
اگر ساختی و جواب داد، این دکمه مال توست.