کتابخانههای جنبی پایتون اکثرا از رابط Python/C برای افزایش سرعت اجرا استفاده می کنند. برای اینکه بتوانیم از این ظرفیت بالقوه استفاده نماییم باید حدالامکان فراخوانی مکرر توابع کتابخانهای را کاهش دهیم. بعبارت عملیتر از استفاده از حلقهها و فراخوانی توابع پشتسر هم و زاید اجتناب نماییم. در این نوشته با مثالهای متعدد نحوه استفاده صحیح از numpy برای افزایش سرعت محاسبات توضیح داده خواهد شد.
بسم الله الرحمن الرحیم
نکته اول برای استفاده از تمام ظرفیت پایتون و کتابخانه numpy اشراف کامل بر تمام امکانات فراهم شده توسط آنها میباشد. برای مثال فرض کنید قرار است مجموع یک میلیون عدد تصادفی را محاسبه نمایید. قطعه کد زیر این کار را برای شما انجام می دهد ولی به قیمت زمانی که هنگام اجرا می بینید!
import numpy as np import time N = 1000000 v = np.random.random(N) t = time.time() sum = 0 for i in range(N): sum += v[i] print(time.time() - t)
وقتی ما ندانیم numpy تابعی به نام sum برای انجام اینکار فراهم کرده است، از کد بالا استفاده خواهیم کرد. حال ببینیم اگر از تابع مذکور استفاده نماییم هزینه زمان اجرا چطور خواهد بود!
import numpy as np import time N = 1000000 v = np.random.random(N) t = time.time() sum = np.sum(v) print(time.time() - t)
بلی! تفاوت زمین تا آسمان. فرض کنید در حال نوشتن یک الگوریتم هوشمند هستیم که function evaluation های متعددی بخاطر ذات این الگوریتمها خواهیم داشت، نتیجه سرعت چند برابری در اجرا خواهد بود. پس قبل از اینکه مثالهای عملیتر از این دست را ببینیم بهتر است با چند تا از امکانات و توابع آماده شده توسط numpy آشنا شویم:
- sum: مجموع عناصر موجود در یک آرایه numpy را محاسبه میکند. در صورتی که آرایه چند بعدی باشد با مشخص کردن محورِ جمع زدن، مجموع در محورهای وارد شده محاسبه میگردد.
- nansum: مشابه sum با این تفاوت که اگر المانهایی از آرایه numpy ورودی برابر Nan باشند، آنها را صفر در نظر میگیرد. بعبارتی تاثیری در خروجی نمیگذارند.
- prod: مشابه sum است با این تفاوت که حاصل ضرب عناصر موجود در آرایه را محاسبه مینماید.
- nanprod: مشابه nansum بوده و با یک در نظر گرفتن المانهای Nan از آنها در محاسبه حاصل ضرب صرف نظر مینماید.
- add: دو بردار را المان به المان جمع می کند. دو بردار ورودی باید دارای ابعاد متناظر و یا قابل پخش باشند.
- subtract: مشابه add برای تفریق دو بردار.
- multiply: مشابه add برای ضرب دو بردار.
- divide: مشابه add برای تقسیم دو بردار.
- power: مشابه add برای به توان رساندن دو بردار (المان به المان به توان هم رسانده می شوند)
- add.accumulate: مجموع عناصر یک بردار را محاسبه کرده و در درایهها انباشته مینماید.
مثالهای استفاده از برخی از توابع فوق را در ذیل مشاهده خواهیم کرد. فرض کنید میخواهیم رابطه زیر را به شکل تابعی بنویسیم:
f_{1}(x) = \sum_{i=1}^{D} x_{i}^{2}
ویرایش سریع و کند برای نوشتن تابع را میتوانید در قطعه کد زیر مشاهده نمایید:
import numpy as np import time def f1_fast(x): return np.sum(x ** 2) def f1_slow(x): o = 0 for i in range(np.size(x)): o += x[i] ** 2 return o N = 1000000 v = np.random.random(N) t = time.time() print(f1_fast(v)) print(time.time() - t) t = time.time() print(f1_slow(v)) print(time.time() - t)
همانطور که می توانید ببینید سرعت اجرای تابع اول چند ده برابر تابع دوم میباشد. به مثال دوم زیر توجه نمایید:
f_{2}(x) = \frac{1}{4000}\sum_{i=1}^{D} x_{i}^{2} - \Pi_{i=1}^{D} cos(\frac{x_{i}}{\sqrt{i}}) + 1
پیاده سازی سریع این تابع را در قطعه کد زیر میتوانید مشاهده نمایید:
import numpy as np import time def f2_fast(x): return np.sum(x ** 2) / 4000 - \ np.prod(np.cos(x / np.sqrt(np.arange(1, np.size(x) + 1)))) \ + ۱ def f2_slow(x): o1 = -1 o2 = 0 for i in range(np.size(x)): o1 *= np.cos(x[i] / np.sqrt(i+1)) o2 += x[i] ** 2 o2 /= 4000 return o1 + o2 + 1 N = 1000000 v = np.random.random(N) t = time.time() print(f2_fast(v)) print(time.time() - t) t = time.time() print(f2_slow(v)) print(time.time() - t)
تفاوت از زمین تا آسمانی که گفته شد به معنای واقعی اینجا قابل مشاهده است! سرعت اجرای تابع سریع حدود صد برابر بیشتر از پیاده سازی کند است (البته متناسب با پردازنده این نسبت می تواند برای شما متفاوت باشد). شاید در نگاه اول نوشتن تابع f2 بشکلی که در قطعه کد بالا مشاهده می شود و سرعت اجرای بسیار مناسبی دارد، سخت به نظر بیاید ولی با اندکی تمرین و ممارست قضیه بسیار طبیعی به نظر خواهد رسید. مثال بعدی برای پیاده سازی عبارت زیر بکار می رود:
f_{3}(x) = \sum_{i=1}^{D} (\sum_{j=1}^{i} x_{j})^{2}
قطعه کد زیر نحوه پیاده سازی این تابع با numpy را نشان میدهد:
import numpy as np def f3(x): return np.sum(np.add.accumulate(x) ** 2)
باز احتمالا در نگاه اول پیاده سازی تابع f3 در پایتون نامفهوم و گنگ بنظر بیاید. در عبارت ریاضی مجموع درایهها را تا درایه اول، دوم و … محاسبه می کنیم و سپس همه آنها را جمع می بندیم. پس مجموع درایههای آرایه انگار انباشته شده است (add.accumulate) و سپس تمام آنها جمع زده شده است (sum). این سبک پیاده سازی روابط با پایتون حتی برای عبارات شرطی هم قابل استفاده است. برای مثال فرض کنید می خواهیم حاصل جمع تابعی از درایه های یک بردار را محاسبه نماییم. این تابع شرطی است به این شکل که اگر درایه از نیم کمتر باشد خروجی، درایه به توان دو و اگر از نیم بیشتر باشد خروجی، درایه به توان سه خواهد بود. پیاده سازی سریع و کند را می توانید در قطعه کد زیر مشاهده نمایید:
import numpy as np import time def f4_fast(x): return np.sum((x > 0.5) * (x ** 2) + (x <= 0.5) * (x ** 3)) def f4_slow(x): o = 0 for i in x: if i > 0.5: o += i ** 2 else: o += i ** 3 return o N = 1000000 v = np.random.random(N) t = time.time() print(f4_fast(v)) print(time.time() - t) t = time.time() print(f4_slow(v)) print(time.time() - t)
ذکر این نکته درباره کد بالا ضروری است که حاصل مقایسه x>0.5 آرایه از مقادیر boolean می باشد. درایههایی که شرط بر قرار است True و درایههایی که شرط برقرار نیست، False خواهد بود. وقتی اقدام به استفاده از این آرایه در محاسبات ریاضی می کنیم مقادیر True مانند عدد یک و مقادیر False مانند عدد صفر استفاده می شوند.
هدف مثالهای ارائه شده در این نوشته ایجاد پایه ای برای یادگیری استفاده صحیح از امکانات کتابخانه numpy میباشد. امیدواریم این مثالها از نظر تعداد و تنوع به نوعی بوده باشند که قدرت استقرا را به خواننده انتقال دهند.