焼きなまし法によるエンジニアライフプランニング

# はじめに
[Qiita Engineer Festa 2024](https://qiita.com/official-campaigns/engineer-festa/2024)のテーマの一つに、**「自分が考える最高のエンジニアライフプランについて紹介しよう」** というテーマがあります。
とても面白いテーマで記事を書きたいと思ったのですが、私にはまだエンジニアのライフプランについて語れるほどの経験はありません。

そこで、焼きなまし法というアルゴリズムを用いて最高のエンジニアライフを送る方法について考察してみました。

# 最高のエンジニアライフの定義
最高のエンジニアライフを送る方法を考えるために、まずは **「最高のエンジニアライフとは何か」** を定義しなくてはなりません。
何を持って最高のエンジニアライフとするかは人それぞれです。そこでこの記事では年収、家族との時間、やりがいなど何でもいいですが、<font color="red">各人がそれぞれ一番重視している何かが最大化されている状態を「最高のエンジニアライフ」と定義します。</font>

そして、問題を扱いやすくするために、ある人の現状を入力として一番重視している何かを出力する関数（以後、目的関数と呼ぶ）が存在すると考えます。

# 目的関数はどのような形をしているか
人それぞれ何を一番重視するかが違ううえ、無数の変数を持つ目的関数の中身はブラックボックスと考えて良いでしょう。
しかし、**目的関数がとても複雑な形をしている事は予想できます。**

例えば、年収を最重要視している人の目的関数は下記のグラフのようになるのでは無いでしょうか。
どの業界を選ぶか、業界のうちどの企業を選ぶか、その企業でどう働くかなどの条件によってその人の年収（＝その人にとっての「最高のエンジニアライフ度」）が決まるイメージです。

![スクリーンショット 2024-06-30 23.29.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2885419/a499287e-6cd0-44b7-9fbc-6f9a2db910b7.png)

# 目的関数の最大値を求める
関数の最大値を求めたい場合、どうすればよいでしょう。
複雑な形の関数であっても、関数の形が分かっていれば微分して変極点（グラフの山のトップ）を全て求めて、その中で最大の変極点を選べば良いです。

しかし、**今回はグラフの形が分からない**ため別の方法を考える必要があります。

そして、グラフのすべての点でどのくらいのスコアになるのかを調べる**全探索もお勧めできません。** <font color=red>エンジニアライフは長くて約50年なのに対して、調べなくてはならない領域が広すぎるからです。</font>

## 山登り法
グラフの形が分からずとも、現状での目的関数の出力を知る事はできます。
そして、現在の値の近傍の内で最も良い点に移動し続けられれば十分な時間が経過した後、目的関数の極大値の一つに辿り着けます。これを、**山登り法**と呼びます。

山登り法を採択した場合どのようなエンジニアライフを送る可能性があるのか、年収を最重要視している人を例にして具体的に考えてみます。

Aさんは新卒入社した企業で一年間働いて年収が500万円だったとします。先輩や上司がいくら貰っているのかは聞きずらいので分かりません。

サボりながら働いていた上期に比べて、サボるのを辞めた下期はボーナスが増えました。
この場合Aさんは**現在の自分の年収と比べて、もう少しサボった自分は数万円年収が下がるであろうこと、もう少し頑張った自分の年収がもう少し高い事をなんとなく想像できると思います。**

なので、年収を上げたいAさんは来季はもう少し頑張って働くという選択を取る事ができます。この繰り返しで、毎期自分の仕事ぶりを反省して改善点を見つけ、改善していけばAさんの年収はどんどん増えていくことになります。

![スクリーンショット 2024-07-01 8.18.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2885419/8b9c8810-dac5-a6aa-b002-683fc61fa8fd.png)

## 山登り法の問題点
では、最高のエンジニアライフプランとは山登り法によるライフプランニング、つまり **「自分が大事にしている何かを最大化するように少しずつ改善していくこと」** なのでしょうか。

<font color=red>そうではありません。</font>
山登り法には限界があります。目的関数のグラフの極大値（山の頂上）に辿り着いたとき、右にいっても左にいっても目的関数の出力値が減ることになり、それ以上進めなくなってしまうからです。**山登り法では狭い範囲での最適解である局所最適解に到達する事は可能ですが、全体を見渡した時の最適解である大域的最適解に至る可能性が低い**ということです。

複雑な形をした目的関数のうち、初期値がどこなのかが分からないためこれは大問題です。
初期値が、自分が選びうる選択肢のうち最も頂上が高い山の麓であった場合問題無いですが、その確率はかなり低いでしょう。初期値が自分が選びうる選択肢のうちそれほど高くない山だった場合、山登り法でどれだけ改善を積み重ねても低いところで頭打ちになってしまいます。

![スクリーンショット 2024-07-01 8.38.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2885419/abdaf0b0-0488-52d6-3783-0431fe7f6e06.png)

そして、目的関数の形は自分が歩んできた道のりと、その近傍しか分かっていないので今自分がいる山の頂上がどうなっているかは、**登り切ってみないと分からない**ということになります。

以上の事から、**山登り法は最高のエンジニアライフを送る方法とは言えなさそうです。**

# 焼きなまし法
そこで登場するのが**焼きなまし法**（シミュレーテッド・アニーリング法）という、大域的最適解を求めるアルゴリズムです。

焼きなまし法では、取りうる点の中からランダムにサンプリングを行いその点のスコアが現在の点でのスコアより高いなら新しい点へ移動し、**現在のスコアより低い点だとしてもある確率で新しい点へ移動する**ことを繰返します。
そして、現在のスコアより低い点に移動する確率を段々減らして行くことで、最後の方は山登り法と同様にスコアは改善されるのみになります。これによって、**局所最適解にとどまってしまう確率を減らしつつ大域的最適解を探索する事ができます。**

スコアの改悪を受け入れる確率$p$は、現在のスコアと次の探索点のスコアの差$\Delta{E} = score(x_{new}) - score(x_{old})$と、ステップごとに減っていく変数$T$を用いて、$p = \{exp{(\frac{\Delta{E}}{T})}}$と定義します。

新しいスコアが現在のスコアより高い場合、${\frac{\Delta{E}}{T} > 0}$になるため常に$p > 1$となります。
一方、新しいスコアが現在のスコアより低い場合、${\frac{\Delta{E}}{T} < 0}$になるため、$p$は、$T$に比例し、$\Delta{E}$に反比例します。
簡単に言い換えると、
- $T$に関しては、値が大きい**序盤ほどスコアが悪くなる変化を受け入れやすく、後半になるほど改悪は受け入れられづらくなります。**
- $\Delta{E}$に関しては、**悪くなる度合いが大きい変化は受け入れられにくい**ということです。

動かしてみると下記のアニメーションのように探索を進め、ちゃんと大域的最適解を見つけてられました。

![sample.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2885419/a3a2eb2a-bb92-c59b-aeab-bb82975fd42b.gif)

<details open><summary>実装（折り畳めます）</summary>

```python
import numpy as np
from matplotlib import pyplot as plt
import matplotlib.animation as animation

# 目的関数
def score(x):
    return 10*np.sin(x) * 20*np.cos(3*x) -30*x*x + 1000

# 初期設定
T = 25
TOTAL_STEP = 150

history = np.zeros((TOTAL_STEP, 2))
x_sample = np.random.uniform(-5, 5, TOTAL_STEP)
best_score = score(x_sample[0])
current_x = 0

# メインループ
for step in range(TOTAL_STEP):
    temp_score = score(x_sample[step])
    if np.random.rand() < np.exp((temp_score - best_score) / T):
        best_score = temp_score
        current_x = x_sample[step]
    history[step][0] = current_x
    history[step][1] = best_score
    T *= 0.9

# 図と軸の設定
fig, ax = plt.subplots()
x = np.linspace(-5, 5, 400)
y = score(x)
ax.plot(x, y, 'r-')  # 目的関数を赤い線でプロット

point, = ax.plot([], [], 'bo')  # サンプリングポイントを青い点でプロット
ax.set_xlim(-5, 5)
ax.set_ylim(min(y) - 10, max(y) + 10)
ax.set_xlabel('x')
ax.set_ylabel('Score')

# ステップ数表示
step_text = ax.text(0.05, 0.9, '', transform=ax.transAxes)

# 初期化関数
def init():
    point.set_data([], [])
    step_text.set_text('')
    return point,

# アニメーション関数
def animate(i):
    point.set_data(history[i][0], history[i][1])
    # 時間
    step_text.set_text('step = {0}'.format(i))
    return point, step_text

# アニメーションの作成
ani = animation.FuncAnimation(fig, animate, init_func=init, frames=TOTAL_STEP, interval=100, blit=True)

# アニメーションの表示
plt.show()
```

</details>

焼きなまし法によって、複雑かつグラフの形が未知の場合でも大域的最適解を見つける事ができます。

# 結論
私が考える最高のエンジニアライフプランは、焼きなまし法に従って**若手のうちはある程度自分が重視しているものを減らす選択肢も受け入れる前提で色々な業界・技術・働き方を経験し、ベテランになるにつれて段々今の自分が活躍できる分野、企業でコツコツ改善に取り組むという**プランです。

ありきたりな結論ですが、直観にも合っていて上手くいきそうな気がします。