本日のメニューはこちら
- 小数の掛け算を実装していて、値が突然 0 になる現象に困った ことはありませんか?
- その問題、計算の仕方を 少し工夫するだけで解消できる かもしれません。
「ちょっと面白そう?」と思った方は、ぜひ覗いて行ってください。
こんにちは。秋田県のIT企業、北日本コンピューターサービスのR&Dチーム「AUL(アウル)」に所属しています。トラフクロウです。
僕は日頃から、研究開発の一環で、AIが出力する小さな数値をたくさん扱っています。
先日、とあるアルゴリズムの開発中に、
「明らかに違うデータを解析しているはずなのに、なぜか同じ結果が得られてしまう」
という現象に遭遇し、新年早々「?」を浮かべておりました。
フタを開けてみれば初歩的な問題だったのですが、
その問題の起こりと、解決方法が面白いなと感じたので、
今回記事にしてみました。
小数の掛け算を繰り返すと値が0になる問題
問題が起きたアルゴリズムでは、次のような処理が行われていました(Python)。
小数をひたすら掛け算しつづけ、最後に $n$ 乗根をとる処理です。
✋「n乗根って何だっけ?」って人はこちら
$n$ 乗根(じょうこん)とは、
「$n$回掛け算することで、この数になる数字は何でしょう?」
ということを表現するための数学用語です。
たとえば、「$32$ の $5$ 乗根は?」と聞かれれば、
「$5$ 回掛け算することで、$32$ になる数字は何でしょう?」
と同じ意味になります。ちなみに答えは $2$ です($2 \times 2 \times 2 \times 2 \times 2 = 32$)。
また、$32$ の $5$ 乗根は
$$
32^{\frac{1}{5}}
$$
のように、分数のべき乗で書かれることもあります(この $\frac{1}{5}$ を指数といいます)。
見栄えが悪いような気もしますが、
$$
32 = 2^5
$$
ということを考えると
$$
32^{\frac{1}{5}} = (2^5)^{\frac{1}{5}} = (2)^{5 \times \frac{1}{5}} = 2^1 = 2
$$
のように(見ため上は)スムーズな式変形ができるので、とてもよくできた表記です。
# array には 0~0.2 の範囲の小数がたくさん入っている(0は除外している)
multiple = 1
for n in array:
# なんやかんや、いろいろな処理
# ここで小数の掛け算を計算
multiple = multiple * n
# なんやかんや、いろいろな処理
# 掛け算結果の n 乗根を計算(n は掛け算した小数の数)
n_root = multiple ** (1/len(array))
この最後の「n_root」に、データ分析を左右する重要な数値が入るはずでした。
しかし、
入力が違うにも関わらず、何を入れても n_root が $0$ になる
という現象に見舞われました。
原因は小数を掛けすぎたことによるアンダーフロー(数値が小さくなりすぎて、コンピューターが表現できる範囲から外れてしまうこと)です。
対数を使ってアンダーフローを回避する
実は、今回のアンダーフローは対数関数を使って回避することができます。
具体的にコードで書くと次のようになります。
✋ 「対数関数って何だっけ?」って人はこちら
対数関数は、
$\log$(ログ)という記号を使って書かれる関数($x$ を決めることで、 $y$ が決まる計算式)
です。具体的には次のように書きます。
$$
y = \log_a(x)
$$
分からざること山のごとし(意味不明)って感じかもですが、読み方は結構単純です。
「$y = \log_a(x)$」を日本語で書くと
「$y$ は $a$ を $x$ にするために必要な掛け算の回数(指数)です」
という意味になります。
たとえば、$3 = \log_2(8)$ の日本語訳は、
「$3$ は $2$ を $8$ にするために必要な掛け算の回数(指数)です」($2 \times 2 \times 2 = 2^3 = 8$)
です。
もう少し自然な読みかえをすると
「$\log_a(x)$ は?」と言われたら、
「$a$ を $x$ にするためには、$a$ を何乗すればいいですか?」
と聞かれているのと同じです(僕はこっちの表現の方がスキ)。
どちらにせよ、毎回言葉で書くのは面倒なので
$y = \log_a(x)$ というコンパクトな書き方をするわけです。
特に指定がなければ、$a$ はどんな数で考えてもOKです。
ですが、対数界隈では
$$
e = 2.71828182845 \cdots
$$
という数字を $a$ に入れることが慣例となっています(自然対数といいます)。
「正気か?」という見た目をしていますが、
この数を使っておくと微分積分のような、ややっこい計算がスムーズにできるのです。
たいがいの人は対数よりも微積の方が難敵なので、みんなこの数を使います(所説あり)。
import math
# array には 0~0.2 の範囲の小数がたくさん入っている(0は除外している)
sum_num = 0
for n in array:
# なんやかんや、いろいろな処理
# 小数の対数をとり、足し合わせる
sum_num = sum_num + math.log(n)
# なんやかんや、いろいろな処理
# 足し合わせた対数の平均をとり、指数関数に入れる
n_root = math.exp(sum_num / len(arr))
下のは、通常の掛け算と、対数を使った場合の計算結果を比較できるコードです。
import math
import random
array_len = 100
# 乱数で小数の配列を作成
random.seed(42) # シード値を固定
array = [random.random() * 0.2 for _ in range(array_len)]
# アンダーフローになるやつ
multiple = 1
for n in array:
# 小数の掛け算を計算
multiple = multiple * n
# 掛け算結果の n 乗根を計算(n は掛け算した小数の数)
n_root_multi = multiple ** (1/len(array))
# 対数を使ってアンダーフローを回避するやつ
sum_num = 0
for n in array:
# 小数の対数をとり、足し合わせる
sum_num = sum_num + math.log(n)
# 足し合わせた対数の平均をとり、指数関数に入れる
n_root_sum = math.exp(sum_num / len(array))
print(f"アンダーフロー: {n_root_multi}")
print(f"対数関数 : {n_root_sum}")
↑のコードで「array_len」の値を 10, 100, 1000, 10000 と変化させた結果は次のようになります。
# array_len = 10
アンダーフロー: 0.04665451830954672
対数関数 : 0.04665451830954673
# array_len = 100
アンダーフロー: 0.06936632105074053
対数関数 : 0.06936632105074059
# array_len = 1000
アンダーフロー: 0.0
対数関数 : 0.07667070740751723
# array_len = 10000
アンダーフロー: 0.0
対数関数 : 0.07399144684409405
途中までは同じ計算結果になっていそうです。
しかし、1000個の小数を考えると、
普通に計算する方はアンダーフローを起こして $0$ なってしまいました。
一方で、対数関数を使った方はうまく計算できていそうですね!
✋「なんでコレでうまくいくの?」って人はこちら
(僕の好きな理論の話です。長めなので、興味のない方はスキップしてください。)
ザックリとした説明になりますが、
対数関数を使うことでアンダーフローを回避できる理由は
次の4ステップで計算が進行するためためです。
- ほぼ $0$ な小さい数字を、対数関数でちょうどいい大きさに直す
- 対数関数が、急激に小さくなる計算を少しずつ小さくなる計算に直してくれる
- 値が消える前に対数の世界で $n$ 乗根を計算できる
- 安全な対数の世界で計算してから、元の世界へ戻ることができる
順番に見て行きましょう。
ステップ1
対数を使うと手元にある数を、適度な大きさの数に読みかえることができます。
$0$ に近い数字は、頭にマイナスはつきますが、$0$ から離れたいい感じの数字になります。
$$
\log_2 (0.00390625) = -8
$$
とにかく、掛け算をしたらなんでも $0$ にしてしまう(近づけてしまう)厄介者路線から
脱却できればよいのです。
ステップ2
対数の世界では、僕たちの世界の「掛け算」が「足し算」として計算できます。
普通に掛け算を行うと、計算結果が激しく増減しますよね(振れ幅が大きい)。
一方で、対数をとってスケールダウンした数字で足し算を行えば、
緩やかな振れ幅で計算を進めることができます($8 \times 32$よりも$3+5$の方が小さい)。
ステップ3
対数の世界では僕たちの世界の「べき乗」が「掛け算」として計算できます。
実はこの性質は、分数のべき乗($n$ 乗根)でも同じです。
そのため、元の世界の $n$ 乗根を求めようと思ったら、
対数の世界で $\frac{1}{n}$ をかければいいのです(↓のようにね)。
$$
\log_2(2^{\frac{1}{3}}) = \frac{1}{3} \times \log_2(2)
$$
そしてこの $\frac{1}{n}$ をかけるという操作が
アンダーフローを回避するキモになっています。
ステップ1とステップ3の2段構えのスケールダウンを経て
ようやくコンピューターが認識可能な規模までレベルを戻すことができるのです。
ステップ4
ここまでは、対数の世界で計算をしてきました(安全地帯で計算できた!)。
しかし、もともとの数字を、人間の都合で対数の世界に送り込んだわけなので
整合性を取るためには、対数の世界から元の世界に戻してあげる必要があります。
スキルアップのために外部研修へ行った仲間がチームに戻ってこないと困りますよね?
対数の世界から元の世界に呼び戻すために指数関数($Y=a^X$)を使います。
具体的には、指数関数の $X$ に対数 $\log_a(x)$ を代入します。
こうすると、対数の中に入れていた $x$ を取り出すことができます($Y = x$ になる)。
長旅お疲れ様です
以上がアンダーフローを回避するまでに起きていることの「あらまし」です。
元の世界では超スピードで限界突破してしまう計算過程を
対数の世界に持っていくことで緩やかに計算しているということですね。
余談ですが、
対数関数はデータサイエンスやAI、確率論などの分野で度々登場します。
これも恐らくは、
計算を少しでも簡単にしたい、アンダーフローなどの取りこぼしを減らしたい
という思いに由来するのでしょうね(縁の下の力持ちです)。
最後まで読んでいただきありがとうございました!
最後まで読んでいただきありがとうございました!
今回アンダーフローという基礎的な内容を取り上げた理由は次の2つです。
- 理論と現実の間のギャップが印象的だったから
- ギャップを埋めるために対数関数が頑張っている姿を見て感動したから
理論だけで考える以上は、アンダーフローなんて気にする必要ありません。
どんなに小さな数でも「有限回」掛ける以上は絶対に有限の値になります。
しかし、リアルなITの現場(現実の仕事の中)で考えると理論で得られたはずの数値は
アンダーフローに飲み込まれて消えてしまいました。
「理論なんて頭でっかちなだけじゃん。」
「現実で使えないなら意味がないじゃないか。」
そう思って挫けそうになったところに、
理論の権化といっても過言ではない対数関数が出てきて
理論とリアルの間のギャップを埋めてくれました。
まるでかつてのライバルが
ピンチに駆けつけてくれたかのような気持ちです(アツいですね🔥)。
・現実は理論通りにはうまくいかない
・対数関数が意外なところで助けてくれた
・課題解決にはたくさんのアプローチがある
・やっぱり数学はキレイだな
ともすれば、ちょっとしたテクニックということで片付けられてしまいそうですが
少なくとも僕には色々な発見とドラマのある感慨深い体験でした。



