13. Numba#

علاوه بر آنچه در Anaconda موجود است، این درس به کتابخانه‌های زیر نیاز دارد:

!pip install quantecon

Hide code cell output

Requirement already satisfied: quantecon in /home/runner/miniconda3/envs/quantecon/lib/python3.13/site-packages (0.11.4)
Requirement already satisfied: numba>=0.49.0 in /home/runner/miniconda3/envs/quantecon/lib/python3.13/site-packages (from quantecon) (0.65.1)
Requirement already satisfied: numpy>=1.17.0 in /home/runner/miniconda3/envs/quantecon/lib/python3.13/site-packages (from quantecon) (2.4.6)
Requirement already satisfied: requests in /home/runner/miniconda3/envs/quantecon/lib/python3.13/site-packages (from quantecon) (2.34.2)
Requirement already satisfied: scipy>=1.5.0 in /home/runner/miniconda3/envs/quantecon/lib/python3.13/site-packages (from quantecon) (1.18.0)
Requirement already satisfied: sympy in /home/runner/miniconda3/envs/quantecon/lib/python3.13/site-packages (from quantecon) (1.14.0)
Requirement already satisfied: llvmlite<0.48,>=0.47.0dev0 in /home/runner/miniconda3/envs/quantecon/lib/python3.13/site-packages (from numba>=0.49.0->quantecon) (0.47.0)
Requirement already satisfied: charset_normalizer<4,>=2 in /home/runner/miniconda3/envs/quantecon/lib/python3.13/site-packages (from requests->quantecon) (3.4.7)
Requirement already satisfied: idna<4,>=2.5 in /home/runner/miniconda3/envs/quantecon/lib/python3.13/site-packages (from requests->quantecon) (3.18)
Requirement already satisfied: urllib3<3,>=1.26 in /home/runner/miniconda3/envs/quantecon/lib/python3.13/site-packages (from requests->quantecon) (2.7.0)
Requirement already satisfied: certifi>=2023.5.7 in /home/runner/miniconda3/envs/quantecon/lib/python3.13/site-packages (from requests->quantecon) (2026.6.17)
Requirement already satisfied: mpmath<1.4,>=1.1.0 in /home/runner/miniconda3/envs/quantecon/lib/python3.13/site-packages (from sympy->quantecon) (1.3.0)

لطفاً اطمینان حاصل کنید که آخرین نسخه Anaconda را دارید، زیرا نسخه‌های قدیمی یک منبع رایج خطا هستند.

بیایید با چند import شروع کنیم:

import numpy as np
import quantecon as qe
import matplotlib.pyplot as plt

13.1. مروری کلی#

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

با این حال، همانطور که قبلاً بحث شد، طرح‌های سنتی برداری‌سازی چندین نقطه ضعف دارند:

  • بسیار حافظه‌بر برای عملیات ترکیبی آرایه

  • ناکارآمد یا غیرممکن برای برخی الگوریتم‌ها

یک راه برای دور زدن این مشکلات، استفاده از Numba است، یک کامپایلر در زمان اجرا (JIT) برای Python.

Numba توابع را در زمان اجرا به دستورالعمل‌های کد ماشین بومی کامپایل می‌کند.

وقتی موفق می‌شود، نتیجه عملکردی قابل مقایسه با C یا Fortran کامپایل‌شده است.

علاوه بر این، Numba می‌تواند ترفندهای مفیدی مانند چندنخی نیز انجام دهد.

این درس ایده‌های اصلی را معرفی می‌کند.

Note

برخی خوانندگان ممکن است کنجکاو رابطه بین Numba و Julia باشند، که کامپایلر JIT خود را دارد. در حالی که این دو کامپایلر از بسیاری جهات مشابه هستند، Numba کمتر بلندپروازانه است و تنها تلاش می‌کند زیرمجموعه کوچکی از زبان Python را کامپایل کند. هرچند این ممکن است یک نقص به نظر برسد، اما یک مزیت نیز هست: ماهیت محدودتر Numba استفاده از آن را آسان و در آنچه انجام می‌دهد کارآمد می‌کند.

13.3. نکات ظریف#

استفاده از Numba نسبتاً آسان است، اما همیشه بی‌دردسر نیست.

بیایید برخی از مشکلاتی که کاربران با آن‌ها مواجه می‌شوند را مرور کنیم.

13.3.1. تعیین نوع#

استنتاج موفق نوع، کلید کامپایل JIT است.

در یک محیط ایده‌آل، Numba می‌تواند تمام اطلاعات نوع لازم را استنتاج کند.

زمانی که Numba نتواند تمام اطلاعات نوع را استنتاج کند، خطا صادر می‌کند.

برای مثال، در حالت زیر، Numba قادر به تعیین نوع تابع g هنگام کامپایل iterate نیست.

@jit
def iterate(f, x0, n):
    x = x0
    for t in range(n):
        x = f(x)
    return x

# Not jitted
def g(x):
    return np.cos(x) - 2 * np.sin(x)

# This code throws an error
try:
    iterate(g, 0.5, 100)
except Exception as e:
    print(e)
Failed in nopython mode pipeline (step: nopython frontend)
non-precise type pyobject
During: typing of argument at /tmp/ipykernel_2909/946716698.py (1)

File "../../../../../../tmp/ipykernel_2909/946716698.py", line 1:
<source missing, REPL/exec in use?>

During: Pass nopython_type_inference 

This error may have been caused by the following argument(s):
- argument 0: Cannot determine Numba type of <class 'function'>

در این حالت، می‌توانیم این مشکل را به‌راحتی با کامپایل کردن g برطرف کنیم.

@jit
def g(x):
    return np.cos(x) - 2 * np.sin(x)

iterate(g, 0.5, 100)
2.223875299559663

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

13.3.2. متغیرهای سراسری#

نکته دیگری که هنگام استفاده از Numba باید به آن توجه کرد، نحوه مدیریت متغیرهای سراسری است.

برای مثال، کد زیر را در نظر بگیرید.

a = 1

@jit
def add_a(x):
    return a + x

print(add_a(10))
11
a = 2

print(add_a(10))
11

توجه کنید که تغییر متغیر سراسری هیچ تأثیری بر مقدار بازگردانده‌شده توسط تابع نداشت 😱.

هنگامی که Numba کد ماشین را برای توابع کامپایل می‌کند، متغیرهای سراسری را به‌عنوان ثابت در نظر می‌گیرد تا پایداری نوع را تضمین کند.

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

13.4. حلقه‌های چندنخی در Numba#

علاوه بر کامپایل JIT، Numba پشتیبانی از محاسبات موازی در CPUها ارائه می‌دهد.

ابزار کلیدی برای موازی‌سازی در Numba تابع prange است که به Numba می‌گوید تا تکرارهای حلقه را به صورت موازی در هسته‌های CPU موجود اجرا کند.

برای نمایش، ابتدا به یک قطعه کد ساده تک‌نخی (یعنی غیرموازی) نگاه می‌کنیم.

کد، به‌روزرسانی ثروت \(w_t\) یک خانوار را از طریق قانون شبیه‌سازی می‌کند

\[ w_{t+1} = R_{t+1} s w_t + y_{t+1} \]

در اینجا

  • \(R\) نرخ بازده ناخالص دارایی‌ها است

  • \(s\) نرخ پس‌انداز خانوار است و

  • \(y\) درآمد کار است.

ما هر دوی \(R\) و \(y\) را به عنوان کشش‌های مستقل از یک توزیع لگ‌نرمال مدل‌سازی می‌کنیم.

در اینجا کد است:

@jit
def update(w, r=0.1, s=0.3, v1=0.1, v2=1.0):
    " Updates household wealth. "
    # Draw shocks
    R = np.exp(v1 * np.random.randn()) * (1 + r)
    y = np.exp(v2 * np.random.randn())
    # Update wealth
    w = R * s * w + y
    return w

بیایید نگاهی بیندازیم که چگونه ثروت تحت این قانون تکامل می‌یابد.

fig, ax = plt.subplots()

T = 100
w = np.empty(T)
w[0] = 5
for t in range(T-1):
    w[t+1] = update(w[t])

ax.plot(w)
ax.set_xlabel('$t$', fontsize=12)
ax.set_ylabel('$w_{t}$', fontsize=12)
plt.show()
_images/a9f0a56492edf7e4574d5f7cb6242fbe6dc6ff9de340e69453dfd46e3cb8bcf2.png

حالا فرض کنیم که جمعیت زیادی از خانوارها داریم و می‌خواهیم بدانیم میانه ثروت چه خواهد بود.

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

  1. تعداد زیادی از خانوارها را در طول زمان شبیه‌سازی می‌کنیم

  2. میانه ثروت را محاسبه می‌کنیم

در اینجا کد است:

@jit
def compute_long_run_median(w0=1, T=1000, num_reps=50_000):
    obs = np.empty(num_reps)
    # For each household
    for i in range(num_reps):
        # Set the initial condition and run forward in time
        w = w0
        for t in range(T):
            w = update(w)
        # Record the final value
        obs[i] = w
    # Take the median of all final values
    return np.median(obs)

بیایید ببینیم چقدر سریع اجرا می‌شود:

with qe.Timer():
    # Warm up
    compute_long_run_median()
5.6608 seconds elapsed
with qe.Timer():
    # Second run
    compute_long_run_median()
4.7112 seconds elapsed

برای تسریع این، آن را از طریق چندنخی موازی‌سازی خواهیم کرد.

برای این کار، پرچم parallel=True را اضافه کرده و range را به prange تغییر می‌دهیم:

from numba import prange

@jit(parallel=True)
def compute_long_run_median_parallel(
        w0=1, T=1000, num_reps=50_000
    ):
    obs = np.empty(num_reps)
    for i in prange(num_reps):  # Parallelize over households
        w = w0
        for t in range(T):
            w = update(w)
        obs[i] = w
    return np.median(obs)

بیایید به زمان‌بندی نگاه کنیم:

with qe.Timer():
    # Warm up
    compute_long_run_median_parallel()
1.3017 seconds elapsed
with qe.Timer():
    # Second run
    compute_long_run_median_parallel()
0.9658 seconds elapsed

افزایش سرعت قابل توجه است.

توجه داشته باشید که موازی‌سازی را در سطح خانوارها انجام می‌دهیم نه در طول زمان – به‌روزرسانی‌های یک خانوار منفرد در طول دوره‌های زمانی ذاتاً ترتیبی هستند.

برای موازی‌سازی مبتنی بر GPU، به درس‌های ما درباره JAX مراجعه کنید.

13.5. تمرین‌ها#

Exercise 13.1 و Exercise 13.3 هر دو \(\pi\) را با Monte Carlo از نمونه‌های تصادفی در مربع واحد تخمین می‌زنند.

ما آن‌ها را اینجا تولید می‌کنیم و در u_draws و v_draws ذخیره می‌کنیم تا بتوانیم در هر دو تمرین از آن‌ها استفاده کرده و نتایج را مقایسه کنیم.

n = 1_000_000
rng = np.random.default_rng()
u_draws = rng.uniform(size=n)
v_draws = rng.uniform(size=n)

Exercise 13.1

قبلاً در نظر گرفتیم که چگونه \(\pi\) را با Monte Carlo تقریب بزنیم.

از همان ایده اینجا استفاده کنید، اما کد را با استفاده از Numba کارآمد کنید.

سرعت را با و بدون Numba هنگامی که اندازه نمونه بزرگ است مقایسه کنید.

Exercise 13.2

در سری سخنرانی مقدمه‌ای بر اقتصاد کمی با Python می‌توانید همه چیز درباره زنجیره‌های مارکوف حالت محدود یاد بگیرید.

فعلاً، فقط روی شبیه‌سازی یک مثال بسیار ساده از چنین زنجیره‌ای تمرکز کنیم.

فرض کنید که نوسان بازده یک دارایی می‌تواند در یکی از دو رژیم باشد – بالا یا پایین.

احتمالات انتقال در بین حالت‌ها به شرح زیر است

_images/nfs_ex1.png

به عنوان مثال، فرض کنید طول دوره یک روز است و فرض کنید حالت فعلی بالا است.

از نمودار می‌بینیم که حالت فردا خواهد بود

  • بالا با احتمال 0.8

  • پایین با احتمال 0.2

وظیفه شما شبیه‌سازی یک دنباله از حالت‌های نوسان روزانه طبق این قانون است.

طول دنباله را n = 1_000_000 تنظیم کنید و در حالت بالا شروع کنید.

یک نسخه Python خالص و یک نسخه Numba پیاده‌سازی کنید و سرعت‌ها را مقایسه کنید.

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

اگر کد شما صحیح باشد، باید حدود 2/3 باشد.

Exercise 13.3

در یک تمرین قبلی، از Numba برای تسریع تلاشی برای محاسبه ثابت \(\pi\) با Monte Carlo استفاده کردیم.

اکنون سعی کنید موازی‌سازی را اضافه کنید و ببینید آیا افزایش سرعت بیشتری به دست می‌آورید.

نباید انتظار افزایش بزرگی در اینجا داشته باشید زیرا، در حالی که وظایف مستقل زیادی وجود دارد (کشیدن نقطه و آزمایش اگر در دایره است)، هر کدام زمان اجرای کمی دارد.

به طور کلی، موازی‌سازی زمانی کمتر موثر است که وظایف فردی که باید موازی شوند نسبت به کل زمان اجرا بسیار کوچک باشند.

این به دلیل سربارهای مرتبط با توزیع همه این وظایف کوچک در چندین CPU است.

با این وجود، با سخت‌افزار مناسب، امکان به دست آوردن افزایش سرعت غیر بدیهی در این تمرین وجود دارد.

برای اندازه شبیه‌سازی Monte Carlo، از چیزی قابل توجه استفاده کنید، مانند n = 100_000_000.

Exercise 13.4

در Exercise 13.3 ما همه نقاط تصادفی را قبل از حلقه موازی کشیدیم.

وسوسه‌انگیز است که به جای آن هر نقطه را درون حلقه prange بکشیم، با عبور دادن یک rng تولیدکننده به عنوان آرگومان و فراخوانی rng.uniform() در بدنه حلقه.

آن را امتحان کنید: کد باید اجرا شود و عددی نزدیک به \(\pi\) برگرداند، با این حال یک اشکال ظریف در این رویکرد وجود دارد.

به این صورت بررسی کنید:

  1. تابع خود را چند بار با همان seed فراخوانی کنید و بررسی کنید آیا نتیجه تکرارپذیر است.

  2. تخمین را بارها در طیفی از اندازه‌های نمونه تکرار کنید و پراکندگی آن را با یک نسخه موازی درست مقایسه کنید.

سپس توضیح دهید چه چیزی اشتباه پیش می‌رود و راهی درست برای کشیدن درون یک حلقه موازی ارائه دهید.

راهنمایی: سعی کنید از یک تابع تصادفی قدیمی مانند np.random.uniform() به جای یک Generator استفاده کنید و ببینید چه اتفاقی می‌افتد.

Exercise 13.5

اکنون دو راه درست برای تخمین \(\pi\) به صورت موازی داریم.

یکی همه نقاط را قبل از حلقه می‌کشد، مانند Exercise 13.3.

دیگری آن‌ها را درون حلقه با توابع قدیمی می‌کشد، مانند Exercise 13.4.

سرعت آن‌ها را در n = 100_000_000 مقایسه کنید، از جمله زمان صرف‌شده برای تولید نقاط تصادفی.

Exercise 13.6

در درس ما درباره SciPy، قیمت‌گذاری یک اختیار خرید را در تنظیمی که قیمت سهام پایه یک توزیع ساده و شناخته شده داشت بحث کردیم.

در اینجا یک تنظیم واقعی‌تر را بحث می‌کنیم.

یادآوری می‌کنیم که قیمت اختیار از قانون زیر پیروی می‌کند

\[ P = \beta^n \mathbb E \max\{ S_n - K, 0 \} \]

که در آن

  1. \(\beta\) یک فاکتور تنزیل است،

  2. \(n\) تاریخ انقضا است،

  3. \(K\) قیمت اعمال است و

  4. \(\{S_t\}\) قیمت دارایی پایه در هر زمان \(t\) است.

فرض کنید که n, β, K = 20, 0.99, 100.

فرض کنید که قیمت سهام از قانون زیر پیروی می‌کند

\[ \ln \frac{S_{t+1}}{S_t} = \mu + \sigma_t \xi_{t+1} \]

که در آن

\[ \sigma_t = \exp(h_t), \quad h_{t+1} = \rho h_t + \nu \eta_{t+1} \]

در اینجا \(\{\xi_t\}\) و \(\{\eta_t\}\) IID و نرمال استاندارد هستند.

(این یک مدل نوسان تصادفی است، که در آن نوسان \(\sigma_t\) در طول زمان تغییر می‌کند.)

از مقادیر پیش‌فرض μ, ρ, ν, S0, h0 = 0.0001, 0.1, 0.001, 10, 0 استفاده کنید.

(در اینجا S0 همان \(S_0\) و h0 همان \(h_0\) است.)

با تولید \(M\) مسیر \(s_0, \ldots, s_n\)، تخمین Monte Carlo را محاسبه کنید

\[ \hat P_M := \beta^n \mathbb E \max\{ S_n - K, 0 \} \approx \frac{1}{M} \sum_{m=1}^M \max \{S_n^m - K, 0 \} \]

از قیمت، با اعمال Numba و موازی‌سازی.