【プチ検証】内包表記、Numpyを用いることによる処理速度の向上

# 概要
アイレット株式会社Advent Calendar2023 7日目の記事です！

最近、コードを書いていて、先輩方のコードと見比べた時に、「自分の書いたコードなんか長いな〜」とか「無駄な記述が多く処理が遅くなってしまっているんだろうな・・・」とか思ったりしています。
そんなことをふと思い、「処理速度を速くするソースコードの書き方」に興味を持ちました。もちろん「まずはロジックの組み立て方を見直せ！」と言われてしまえばそれまでなのですが、
今回は「リスト内包表記」と、pythonのライブラリ「Numpy」に着目して、処理速度がどれだけ速くなるのかを検証します。

## Numpyとは
耳にしたことはあったものの、実際にどんなものかまでは知らなかったので調べてみました。
![スクリーンショット 2023-12-03 14.15.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3623937/ed614be4-bcb0-d1e9-827d-0b0ce70a6d08.png)
名前の由来→**Num**erical **Py** thonから来ているそう。
Pythonの、数値計算を効率的に行うためのライブラリです。
処理速度の速さから、データサイエンスや機械学習の分野で重宝されているようです。

※numpy公式サイトによると
>NumPyの大部分は最適化されたC言語のコードで構成されています。これによりPythonの柔軟性とコンパイルされたコードの高速性の両方を享受できます。

とのことで、C言語が元になっているそう。
コンパイル言語である C言語や Javaとは違い、インタプリタ言語であるpythonのループ処理の遅さをカバーしているようです。
※コンパイル言語→ファイルを一括で機械語に変換して処理が実行される
※インタプリタ言語→1行ずつ処理を読み込んで実行されるため（逐次処理）、コンパイル言語に比べて処理速度が遅くなる。



## 検証スタート
テスト用のプログラムをを用意します。内容としてはごく単純で、下記のような処理を行っています。

:::note info
①→任意の長さのリストを生成（今回は　要素数10**6= 1000000でテスト実施）
②→リストの要素1つ1つの要素を2倍
③→②の処理にかかった時間を表示　
:::



### 普通の処理
```python:test1.py
# 1)普通の処理
import time
# テストデータ生成
size = 10**6
data = list(range(size))

# 普通の処理
start_time = time.time()
result = []
for num in data:
    result.append(num * 2)
end_time = time.time()

time_to_process = end_time - start_time
print(f'普通の処理の実行時間: {time_to_process}秒')
```

### リスト内包表記を使った処理
```python:test2.py
# 2)内包表記を使った処理
import time

# データ生成
size = 10**6
data = list(range(size))

# 内包表記を使った処理
start_time = time.time()
result = [num * 2 for num in data]
end_time = time.time()

time_to_process = end_time - start_time
print(f'内包表記を使った処理の実行時間: {time_to_process}秒')

```
### numpyを使った処理
numpyに含まれる、arange関数を使用し、等間隔の行列を作成します。この関数の詳細の説明は[numpyはなぜ速いのか？](#numpyはなぜ速いのか？)で説明するので、ここでは関数の説明は省略します。


```python:test3.py
# 3)numpyを使った処理
import time
import numpy as np

# データ生成
size = 10**6
data = numpy.arange(size)

# numpyを使った処理
start_time = time.time()
result = data * 2
end_time = time.time()

time_to_process = end_time - start_time

print(f'numpyを使った処理の実行時間: {time_to_process}秒')

```

## 検証結果(処理にかかった時間の比較)
1回の実行結果だけを採用するのは統計的に不適切？だと思ったので、
100回の実行結果を、平均を取って整理することにします。
当初書いていたコードを一部修正します。
```python:test1.py
# リスト内包表記と普通の処理の記載の処理速度の比較
# 1)普通の処理
import time
# テストデータ生成
size = 10**6
data = list(range(size))

# 普通の処理
# 実行回数定義
execution_count = 100

# 処理速度を格納するリストを作成
execution_times = []

for _ in range(execution_count):
    start_time = time.time()
    result = []
    for num in data:
        result.append(num * 2)
    end_time = time.time()

    time_to_process = end_time - start_time
    # 実行時間を算出
    execution_times.append(time_to_process)
print(execution_times)

# 100回の試行の平均を算出
avg_execution_times = sum(execution_times)/execution_count
print(f'実行時間平均:{avg_execution_times}秒')

```
```python:test2.py
# 2)内包表記を使った処理
import time

# データ生成
size = 10**6
data = list(range(size))

# リスト内包表記を使った処理
# 実行回数定義
execution_count = 100

# 処理速度を格納するリストを作成
execution_times = []

for _ in range(execution_count):
    start_time = time.time()
    result = [num * 2 for num in data]
    end_time = time.time()

    time_to_process = end_time - start_time
    # 実行時間を算出
    execution_times.append(time_to_process)
print(execution_times)

# 100回の試行の平均を算出
avg_execution_times = sum(execution_times)/execution_count
print(f'実行時間平均:{avg_execution_times}秒')
```
```python:test3.py
# 3)numpyを使った処理
import time
import numpy as np

# データ生成
size = 10**6
data = np.arange(size)


# numpyを使った処理

# 実行回数定義
execution_count = 100

# 処理速度を格納するリストを作成
execution_times = []

for _ in range(execution_count):
    start_time = time.time()
    result = data * 2
    end_time = time.time()

    time_to_process = end_time - start_time
    # 実行時間を算出
    execution_times.append(time_to_process)
print(execution_times)

# 100回の試行の平均を算出
avg_execution_times = sum(execution_times)/execution_count
print(f'実行時間平均:{avg_execution_times}秒')
```

100回の実行結果の平均と、処理速度の比較について整理したものが下記です。
|| 普通の処理 | リスト内包表記 | numpy |
|:-:|:-:|:-:|:-:|
|処理速度【sec】(=avg_execution_timesの値)| 0.059603371620178223  | 0.02856817960739136 | 0.0008226680755615234
|普通の処理と比べた処理の速さ【倍】(小数点第2位まで)|1| 2.09  | 72.45  |   

結果は一目瞭然、numpyを使用する時が圧倒的に速いことが分かりました。
また、内包表記を使うと処理が早くなる、というのは認識していましたが、内包表記を使うと2倍程度処理が早くなることを知って驚きました。
リスト内包表記は可読性が落ちるという懸念がありますが、処理速度向上の観点からは積極的に使っていった方が良いのかな、と感じました。

:::note warn
扱うデータ数や、処理の内容によって結果は変わりうるので、あくまでも大体の比較という位置付けとして認識いただければと思います。
:::



### numpyはなぜ速いのか？
A.ループ処理ではなく、<font color="red">ベクトル演算</font>の仕組みを使っているから。
（確か高校数学、大学の経済学の授業で学習した覚えがある、、:thinking:）
ベクトル演算の仕組みについては、[こちら](https://qiita.com/juri_engineer/items/f641870b0644d2f8d667)が参考になりそうです。

前述の`test3.py`における`arange`関数は、
等間隔の要素（検証の例だと0-99999までの数字）が格納されたベクトルを作る、ということをしています。

`result = data * 2`で、そのベクトルの要素（≒配列の要素の一つ一つの要素と置き換えて考えると分かりやすいかもしれないです）を一気に2倍してくれています。

図で説明すると、このようなイメージです。
![IMG_0353.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3623937/43efcf37-2d52-6385-2398-dd30d6957cb7.png)


そしてresultをvベクトル、dataをuベクトルとすると、その関係性は下記のような式になります。
$$
\vec{v}\\ = 2\vec{u}\\ 
$$

#### 補足）グラフの描画について
```matplotlib```というライブラリを使用し、グラフを描画することができます。
グラフは、```test3.py```に下記のコードを追加し作成しました。
[こちら](https://qiita.com/atchicken/items/04ffc7b4353a348581f7)を参考にしました。
```python:test3.py(追記部分)
import matplotlib.pyplot as plt
# グラフの描画
plt.plot(data, label='data', linestyle='-', marker='.')
plt.plot(result, label='result', linestyle='-', marker='.')
plt.xlabel('Index')
plt.ylabel('Value')
plt.title('Visualization of Data and Result')
plt.legend()
# グラフを表示
plt.show()
```


# 最後に
今回はpythonを使って、処理の高速化をテーマに簡単な検証を行ってみました。
検証を通して、numpyが少し面白そうだな、と思ったのでもう少し深く学んでみたいなと思いました。
（そのためには高校大学時代の数学で学んだベクトルの概念について理解を深めないとな、と思っています）

現時点ではまだまだ「ただ動くコードを書く段階」で精一杯の段階ですが、
ロジックの組み立て方、一つ一つの処理の挙動をしっかり理解する→その上で、「どうしたら処理が早くなるか」を考えコードを書けるようになっていきたいと思っています。

# 参考文献
https://qiita.com/juri_engineer/items/f641870b0644d2f8d667
https://numpy.org/doc/stable/
https://blog.kikagaku.co.jp/numpy-base
https://www.youtube.com/watch?v=gnTxKHMYqFI
https://qiita.com/renesisu727/items/24fc4cd8fa2635b00a0d