ジニ不純度について

ここ数週間にわたってエントロピー関連の話が続きましたので、ついでみたいになるのですが決定木の評価関数として同じようによく使われるジニ不純度についても紹介しておきます。
ジニ係数(はじパタなど)や、ジニ指数(カステラ本など)といった呼び名もあるのですが、経済学で所得格差の指標に用いられるジニ係数と紛らわしくなるので、この記事ではジニ不純度と呼びます。

ジニ不純度の定義

ジニ不純度は、決定木やランダムフォレストなどの機械学習のアルゴリズムにおいて、ノードの純度を計測するための指標の一つとして用いられます。要するに、そのノードに分類される観測値のクラスの内訳が全部同じだとなると不純度は小さく、同じノードに複数のクラスの観測値が混在してたら不純度は高くなります。エントロピーと似た挙動ですね。

数式としては次のように定義されます。$n$クラスに分類する問題の場合、$i$番目のクラスのサンプル比率を$p_i$とすると、ジニ不純度は次のように定義されます。

$$I = 1 – \sum_{i=1}^n p_i^2.$$

例えば、3クラスの分類問題を考えると、あるノードの観測値が全部同じクラスだったとします。すると、$p_1 = 1, p_2=0, p_3 = 0$みたいになるのですが、この時$I=0$と、不純度が0になります。また、3クラスが均等に混ざっていた場合、$p_1 = p_2 = p_3 = 2/3$となり、時に不純度は$I = 2/3$となります。$n$クラスの分類問題の場合、この$1-1/n$が最大値です。

もう7年近く前になりますが、初めてこの定義式を見た時は、1から確率の平方和を引くことに対してちょっと違和感を感じました。ただ、これは計算を効率的にやるためにこの形にしているだけで、実際は次の形で定義される値と考えておくと納得しやすいです。

$$I = \sum_{i=1}^n p_i (1-p_i).$$

これ展開すると、$\sum_{i=1}^n p_i – p_i^2$となり、$\sum_{i=1}^n p_i =1$ですから、先に出した定義式と一致することがわかります。

ジニ不純度の解釈

それでは、$p_i(1-p_i)$とは何か、という話なのですが解釈が二つあります。

まず一つ目。ある観測値がクラスiであれば1、クラスi出なければ0というベルヌーイ試行を考えるとその分散がこの$p_i(1-p_i)$になります。これを各クラス分足し合わせたのがジニ不純度ですね。

クラスiである確率が0の場合と1の2種類の場合がまじりっけ無しで不純度0ということでとてもわかりやすいです。

もう一つは、そのノードにおける誤分類率です。あるノードにおける観測値を、それぞれ確率$p_i$で、クラス$i$である、とジャッジすると、それが本当はクラス$i$ではない確率が$(1-p_i)$ありますから、誤分類率は$\sum_{i=1}^n p_i (1-p_i)$となります。

あるノードの観測値のクラスが全部一緒なら誤分類は発生しませんし、$n$クラスが均等に含まれていたら誤分類率は最大になりますね。不純度の定義としてわかりやすいです。

エントロピーと比較した場合のメリット

決定木の分割の評価として使う場合、エントロピーとジニ不純度は少しだけ異なる振る舞いをし、一長一短あるのですが、ジニ不純度には計算効率の面で優位性があるとも聞きます。というのも、$p\log{p}$より$p^2$のほうが計算が簡単なんですよね。scikit-learnがginiの方をデフォルトにしているのはこれが理由じゃないかなと思ってます。(個人的な予想ですが。)

ただ、実際に実験してみると計算速度はそこまで大差はないです(np.log2の実装は優秀)。なんなら自分で書いて試したらエントロピーの方が速かったりもします。とはいえ、$p\log{p}$の計算はちゃんとやるなら、$p=0$ではないことをチェックして場合分けを入れたりしないといけないのでそこまできちんと作ったらジニ不純度の方が速くなりそうです。

Pythonでの実装

数式面の説明が続きましたが、最後に実装の話です。実は主要ライブラリではジニ不純度を計算する便利な関数等は提供されていません。まぁ、簡単なので自分で書けば良いのですが。

scikit-learnのソースコードを見ると、ここで実装されて内部で使われていますね。

確率$p$のリストが得られたら、全部二乗して足して1から引けば良いです。
$1/6+1/3+1/2=1$なので、確率のリストは$[1/6, 1/3, 1/2]$としてやってみます。

import numpy as np


p_list = np.array([1/6, 1/3, 1/2])
print(1-(p_list**2).sum())
# 0.6111111111111112

まぁ、確かにこれならわざわざライブラリ用意してもらわなくてもって感じですね。

割合ではなく要素数でデータがある場合は全体を合計で割って割合にすればよいだけですし。

以上で、ジニ不純度の説明を終えます。エントロピーと比べて一長一短あるのでお好みの方を使ってみてください。

Pythonによる各種エントロピーや相互情報量の計算

エントロピーや相互情報量の記事が続いていますが、今回の記事で計算の実装方法を紹介して一旦区切りとします。

エントロピーも相互情報量も数式はそこまで難しくないので、numpyで定義通り計算しても良いのですが、エントロピー関係はSciPyに、相互情報量はscikit-learnに用意されているので今回の記事ではそれを使っていきます。

計算対象のデータは、[“a1”, “a2”, “a1”, “a1”, “a2”] みたいにローデータの一覧で保有している場合もあれば、”a1″が3個で”a2″が2個のようにカウント済みのものがある場合もあると思うのでそれぞれ説明していきます。

エントロピーの計算

まず一番基本的なエントロピーの計算からです。これは、scipy.stats.entropy メソッドを使います。
参考: scipy.stats.entropy — SciPy v1.11.3 Manual

基本的な引数はpkなので、確率の一覧を渡すのが想定されていますが、和が1でないなら1になるように正規化してくれるのでサンプルがある場合は個数を渡しても大丈夫です。また、base引数で対数関数の底を指定でき、デフォルトが$e$なので、情報理論で使う場合は$2$を指定しましょう。

やってみます。

import numpy as np  # データ作りに利用
import pandas as pd  # データ作りに利用
from scipy.stats import entropy


pk = np.array([1/2, 1/3, 1/6])  # 確率の一覧が得られた場合。
print(entropy(pk, base=2))
# 1.459147917027245

count_list = np.array([3, 2, 1])  # データの個数の場合
print(entropy(count_list, base=2))
# 1.4591479170272446

# カウント前のデータの一覧がある場合
data_sr = pd.Series(["a1", "a1", "a1", "a2", "a2", "a3"])
# value_counts()で数えあげたものをentropyに渡す
print(entropy(data_sr.value_counts(), base=2))
# 1.4591479170272446

結合エントロピーの計算

次は結合エントロピーです。エントロピーを単純に2次元に拡張したやつですね。(条件付きエントロピーではないので注意してください、

例えば次のような例を考えましょうか。

b1b2
a141
a223

結合エントロピーの場合はですね、元のカウントデータを2次元から1次元に並び替えて渡します。

matrix_data = np.array([[4, 1], [2, 3]])
print(matrix_data)
"""
[[4 1]
 [2 3]]
"""

# ravel か flattenで1次元化して計算する
print(entropy(matrix_data.ravel(), base=2))
# 1.8464393446710157

# 標本データがある場合
df = pd.DataFrame({
    "A": ["a1", "a1", "a1", "a1", "a1", "a2", "a2", "a2", "a2", "a2"],
    "B": ["b1", "b1", "b1", "b1", "b2", "b1", "b1", "b2", "b2", "b2"],
})

# カウントしたデータを使う
print(df.groupby(["A", "B"]).size())
"""
A   B
a1  b1    4
    b2    1
a2  b1    2
    b2    3
dtype: int64
"""

print(entropy(df.groupby(['A', 'B']).size(), base=2))
# 1.8464393446710157

条件付きエントロピー

次は条件付きエントロピーです。残念なことなのですが、メジャーなライブラリでは条件付きエントロピー専用の関数は提供されていません。

そこで、$H(A|B) = H(A, B) – H(B)$などのエントロピー間の関係式を使って計算することになります。相互情報量も含めて、$H(A|B) = H(A) – I(A; B)$などで計算してもいいのですが、SciPyで完結できるので最初の式のほうが良いでしょう。

先ほどの表データをサンプルとします。$H(B)$については、表データを縦に足し合わせてBだけのカウントデータを作って計算します。

data_B = matrix_data.sum(axis=0)
print(data_B)
# [6 4]

# H(B)の計算
entropy_B = entropy(data_B, base=2)
print(entropy_B)
# 0.9709505944546688

# H(A, B)の計算
joint_entropy = entropy(matrix_data.ravel(), base=2)
print(joint_entropy)
# 1.8464393446710157

# H(A|B) = H(A, B) - H(B)
conditional_entropy_A_given_B = joint_entropy - entropy_B
print(conditional_entropy_A_given_B)
# 0.8754887502163469

# 標本データがある場合
entropy_B = entropy(df["B"].value_counts(), base=2)
joint_entropy = entropy(df.groupby(["A", "B"]).size(), base=2)

conditional_entropy_A_given_B = joint_entropy - entropy_B
print(conditional_entropy_A_given_B)
# 0.8754887502163469

以上で、3種類のエントロピーが計算できました。

相互情報量

最後に相互情報量の計算方法です。

$I(A; B) =H(A)-H(A|B)$など複数の表現方法があるので、ここまでに計算してきた値から算出することもできます。

entropy_A = entropy(df["A"].value_counts(), base=2)
print(entropy(df["A"].value_counts(), base=2) - conditional_entropy_A_given_B)
# 0.12451124978365313

ただ、scikit-learnに専用のメソッドがあるのでこちらの使い方も見ておきましょう。
参考: sklearn.metrics.mutual_info_score — scikit-learn 0.18.2 documentation

引数は、mutual_info_score(labels_truelabels_predcontingency=None)
となっており、標本データを受け取るのが標準的な使い方で、その第一,第二引数をNoneにしてcontingency引数にカウントデータを渡すこともできます。(contingencyがNoneでない場合はこれが優先されて、先の二つの引数が無視されます。)

1点注意しないといけないのは、entropyと違って対数の底が指定できず、自然対数に固定されてしまうことです。底を$2$で考えたい場合は、$\ln{x}/\ln{2} = \log_2{x}$を使って変換が必要です。

from sklearn.metrics import mutual_info_score


# np.log(2)で割ることを忘れない
# カウントした表データがある場合
print(mutual_info_score(None, None, contingency=matrix_data)/np.log(2))
# 0.12451124978365345

# 標本データがある場合
print(mutual_info_score(df["A"], df["B"])/np.log(2))
# 0.12451124978365345

計算の都合上超軽微な誤差がありますが、それ以外は想定通りの値が得られていますね。

以上で、相互情報量も計算できるようになりました。

シャノンの補助定理とその応用

今回も引き続き情報量(エントロピー)関係の記事です。直近、エントロピーに関する記事を複数書いていますが、その中でいくつか証明をしていない性質があります。シャノンの補助定理という面白い定理を使うとそれらが証明できるので見ていきます。

情報量の話なのでこの記事では底を省略して書いた対数関数の底は$2$とします。$\log=log_2$です。一方で、自然対数も使うのでそれは$\ln=\log_e$と書きます。

早速、シャノンの補助定理を見ていきます。

定理

二つの確率事象系$A = \{a_k | k=1,\dots, n \}$と$B = \{b_k | k=1,\dots, n\}$の確率をそれぞれ$p_k$, $q_k$とします。つまり、
$\sum_{k=1}^n p_k = 1$, $\sum_{k=1}^n q_k = 1$です。この時次の関係が成り立ちます。

$$ – \sum_{k=1}^n p_k\log{p_k} \leq – \sum_{k=1}^n p_k\log{q_k}.$$

以上が、シャノンの補助定理の主張です。左辺は$A$のエントロピーで、右辺は対数関数の中身だけ別の確率事象系の確率になっていますね。

証明

定理を証明する前に、自然体数に関する$\ln{x} \leq x – 1$ $(x > 0)$という関係を使うのでこれを先に証明します。

正の実数$x$に対して、$f(x) = x -1 – \ln{x}$ とおきます。すると$f'(x) = 1 – \frac{1}{x}$となります。$f'(x)$の値を見ていくと、 $0 < x < 1$ の時$f'(x)<0$であり、$f'(1)=0$、そして$1 < x$の時$f'(x)>0$となるので、$x=1$で最小値をとり、その値は$f(1) = 1-1-\ln{1} = 0$です。

よって、$f(x) \leq 0$であり、$\ln{x} \leq x – 1$ が示されました。

ここから定理の証明に入ります。定理の左辺から右辺を引いたものを考えて、底の変換をし、先ほどの関係式を適用します。

$$ \begin{align}
– \sum_k p_k \log{p_k} + \sum_k p_k\log{q_k} &= \sum_k p_k \log{\frac{q_k}{p_k}}\\
&=\frac{1}{\ln{2}} \sum_k p_k \ln{\frac{q_k}{p_k}}\\
&\leq \frac{1}{\ln{2}} \sum_k p_k \left(\frac{q_k}{p_k} – 1 \right)\\
&= \frac{1}{\ln{2}} \sum_k (q_k – p_k)\\
&= \frac{1}{\ln{2}} \sum_k q_k – \frac{1}{\ln{2}} \sum_k p_k\\
&=0\end{align}.$$

よって、$ – \sum_{k=1}^n p_k\log{p_k} \leq – \sum_{k=1}^n p_k\log{q_k}$ が示されました。等号が成立するのは$p_k = q_k$の場合です。

この補助定理を使って、エントロピー関連の性質を見ていきます。

応用1. エントロピーの最大値

最初に見ていくのは、「平均情報量(エントロピー)の定義について」で見た、エントロピーの範囲、$0 \le H(a) \le \log{n}$ の最大値の方です。これは、$q_k = \frac{1}{n}$としてシャノンの補助定理を使うと証明できます。

$$H(a) = – \sum_k p_k\log{p_k} \leq – \sum_k p_k\log{\frac{1}{n}} = \log{n}$$

となります。

応用2. 相互情報量が0以上であること

次に証明するのは、「相互情報量について」で最後に書いた、相互情報量が非負の値を取るという話です。

これは、シャノンの補助定理をそのまま2次元の確率分布に拡張して使います。
$k = 1, \dots, n$, $l = 1, \dots, m$とし、$\sum_{k, l} p_{kl} = 1$、$\sum_{k, l} q_{kl} = 1$とすると、

$$- \sum_k \sum_l p_{kl} \log{p_{kl}} \leq – \sum_k \sum_l p_{kl} \log{q_{kl}}$$

ですから、

$$\sum_k \sum_l p_{kl} \log{\frac{p_{kl}}{q_{kl}}} \geq 0$$

となります。ここで、$p_{kl} = P(a_k, b_l)$とし、$q_{kl} = P(a_k) P(b_l)$とすると、

$$\sum_k \sum_l P(a_k, b_l) \log{\frac{P(a_k, b_l)}{P(a_k) P(b_l)}} \geq 0$$

となります。この左辺が相互情報量$I(A; B)$の定義そのものなのでこれが非負であることが示されました。

応用3. 条件付きエントロピーがエントロピーより小さいこと

3つ目はさっきの相互情報量の話から続けて導けるものです。「結合エントロピーと条件付きエントロピー」で、重要な性質として$H(A|B) \le H(A)$が成り立つと証明無しで言いました。

これはシンプルに、$I(A; B) = H(A) – H(A|B)$であり、$I(A; B) \geq 0$なので、移項するだけで、$$H(A) \geq H(A|B)$$ が言えます。

まとめ

この記事ではシャノンの補助定理とその応用をいくつか紹介しました。応用1,2,3で証明したことはそれぞれの記事で証明無しで紹介していたのを個人的にモヤモヤしていたので、それを解消できてよかったです。

エントロピーの最大値についても、相互情報量の非負性についても結構シンプルな主張なのですが、シャノンの補助定理無しで証明するのは結構難しいのでこの定理の偉大さを感じます。

ここしばらくエントロピーの理論面の記事が続いていますが、次あたりでPythonで実際に計算する方法を紹介してこのシリーズを完結させようかなと思っています。

相互情報量について

相互情報量の定義と意味

エントロピー(情報量)関係の記事の3つ目です。前回の記事の最後で、エントロピー、結合エントロピー、条件付きエントロピーの間に成り立つ関係式で4つの量が等しくなるという話をしました。
参考: 結合エントロピーと条件付きエントロピー

その値のことを、相互情報量と呼び、$I(A; B)$と書きます。($A$と$B$は対等で交換も可能なのに$I(A, B)$ではなく$I(A; B)$と書く理由が気になりますね。)

改めて式を書くとこうなります。

$$\begin{align} I(A;B) &= H(A)-H(A|B)\\
&=H(B)-H(B|A)\\
&=H(A)+H(B)-H(A, B)\\
&=H(A, B)-H(A|B)-H(B|A)\end{align}$$

現実的には、一番上の行の等式で定義することが多いようです。

事象$A$の不確実性である、$H(A)$から、事前情報として$B$を知っている場合の$A$の不確実性$H(A|B)$を引いているわけですから、$I(A;B)$は、事前情報として$B$を知ったことによって減った$A$の不確実性と考えられます。$B$について知ることで分かった$A$に関する情報の方がわかりやすいかもですね。

個人的には$H(A) = I(A; B) + H(A|B)$ と移項して、{Aの情報量} = {Bを知った時点で得られるAの情報量} + {Bを知った後にAを観察して初めてわかるAの情報量} と考える方が理解しやすいと思っています。

この相互情報量は情報理論において非常に重要な概念です。これは、2つの確率変数間の情報の共有度合いを測る指標として使用されています。

相互情報量の確率表現

実用上の話として、$H(A)-H(A|B)$のままでは計算しにくいので確率$P$の式として相互情報量を表すことを目指します。一番上の定義をスタートとして計算してみましょう。

$$\begin{align}I(A;B) &= H(A)-H(A|B)\\
&= -\sum_{A}P(a)\log{P(a)} – \left\{ – \sum_{A}\sum_{B} P(a, b) \log{P(a|b)} \right\}\\
&= -\sum_{A}\sum_{B}P(a, b)\log{P(a)} + \sum_{A}\sum_{B} P(a, b) \log{\frac{P(a,b)}{P(b)}} \\
&= \sum_{A}\sum_{B} P(a, b) \log{\frac{P(a, b)}{P(a)P(b)}}.
\end{align}$$

なかなか綺麗な形に導けましたね。この形で見ると$A$, $B$を入れ替えても同じ値になることなどが容易にわかります。

Wikipedia などもそうですが、この確率表現の形の方を相互情報量の定義として、先に挙げた等式類を定理として導く流儀もあるようです。というより僕自身も元々そちらで理解していました。

相互情報量の性質

相互情報量は常に非負の値を取ります。

$$I(A;B)\ge 0.$$

また、$A$と$B$が独立の時、$P(a, b) = P(a)P(b)$ ですから、$\log$の中身が$1$になり、値が$0$になるため、$I(A;B)=0$となります。

これに関してはここ最近の証明してない他の性質とまとめて別記事で証明を紹介します。

逆に、$I(A; B) \le H(A)$ と $I(A; B) \le H(B)$ もそれぞれ成立します。

$A$の例を見ていきますが、等号が成立するのは $H(A|B)=0$となる場合です。これは、$B$の情報を得た時点で$A$のことが完全にわかってAを知っても情報が増えないケースが該当します。

結合エントロピーと条件付きエントロピー

前回の記事に続いて平均情報量(エントロピー)の話です。

参考: 平均情報量(エントロピー)の定義について

今回の記事ではその2次元版とも言える2種類のエントロピーを紹介します。この2つについてはどうも情報量よりエントロピーと呼ぶことが多いようなのでそのように書きます。また、数式中に和の記号がたくさん登場し、添え字書いていくと大変だし、かえって混乱もまねくので、$\sum_{k=1}^n f(a_k)$ を $\sum_{A}f(a)$のようにシンプルに書きます。

そのため、$A = \{a_k\}$のエントロピーは次のように書きます。

$$H(A) = \sum_{A}P(a)\log{P(a)}.$$

参考にした本は前回の記事同様、平田先生の「情報理論のエッセンス」です。

結合エントロピー

まず紹介するのはエントロピーの2次元版ともいえる、結合エントロピーです。これは二つの事象の集合 $A={a_k}$ と $B={b_l}$に対して定義されます。$a_k$と$b_l$が発生する確率を$P(a_k, b_l)$ とすると、結合エントロピーは次のように定義されます。

$$H(A, B) = – \sum_{A}\sum_{B}P(a, b)\log{P(a, b)}.$$

ここで、$\sum_{A}\sum_{B}P(a, b)=1$であることに注意してください。

これはもう、$A$と$B$の組みあわせとして発生しうる事象を全て列挙してそれに対してエントロピーを計算したようなもので、とてもわかりやすい定義だと思います。

$A$と$B$の結果を知った時に得られると期待できる情報量、とも言えます。

条件付きエントロピー

次に紹介するのは条件付きエントロピーです。これは先ほどの結合エントロピーと少し状況が異なり、あらかじめ$B$に関する情報を知った上で後から$A$についての結果を得たらどれだけの情報量を得られるかを表すものです。

こちらは結合エントロピーと違って、定義をバシッと書いてもわかりにくいので例を取り上げて順番に説明します。

本の例では、$A$がサイコロの目で$\{1, 2, 3, 4, 5, 6\}$、$B$が偶数/奇数というものを取り上げていましたのでここでもそれにならいます。$b_1$が偶数で$b_2$が奇数です。

まず$A$のエントロピーは$H(A)=-\sum_{k=1}^{6}\frac{1}{6}\log{\frac{1}{6}}=\log{6}\fallingdotseq 2.58$ です。

ここで、サイコロをの目を見る前になぜか偶数か奇数かだけわかり、$b_1$ = 偶数だったとしましょう。その後に目を確認するとすると、$2, 4, 6$が出ている確率は$1/3$で、$1, 3, 5$が出ている確率は$0$ということになります。

つまりサイコロの目を見ることで得られる情報量は、

$H(A|b_1) = -\sum_{A}P(a|b_1)\log{P(a|b_1)} = -\sum_{a\in\{2, 4,6\}}\frac{1}{3}\log{\frac{1}{3}} = \log{3} \fallingdotseq 1.58$

となります。情報量が1だけ減っていますね。この1は、$\log{2}$相当の値で、事前に偶数か奇数かという情報を得たことで起こりうる確実性が$1/2$になったことを示しています。

同様に計算すると、事前に奇数だっと知らされた後に出た目を確認することによって得られる情報量$H(A|b_2)$も$\log{3}$だとわかります。

そして、いよいよ本題なのですが、ここで算出した$H(A|b_1)$, $H(A|b_2)$に、$B$の結果が$b_1$であるか$b_2$であるか、要するに偶数であるか奇数であるかの確率をかけて期待値を取ると、偶数か奇数かの事前情報がある場合の$A$のエントロピーの期待値$H(A|B)$を次のように求められます。

$$\begin{align}
H(A|B) &= H(A|b_1)P(b_1) + H(A|b_2)P(b_2)\\
&= \frac{1}{2}\log{3} + \frac{1}{2}\log{3} = \log{3} \fallingdotseq 1.58
\end{align}$$

この$H(A|B)$が条件付きエントロピーです。もっと一般の事例について定義し、$P$の式として表記できるよう変形しておくと、次のようになります。

$$\begin{align}
H(A|B) &= \sum_{B} H(A|b)P(b)\\
&= -\sum_{A}\sum_{B} P(a|b)P(b)\log{P(a|b)}\\
&= -\sum_{A}\sum_{B} P(a, b)\log{P(a|b)}.\\
\end{align}$$

結合エントロピーとぱっと見似ていますがよく見ると違いますね。$\log$の中身が結合確率ではなく条件付き確率になっています。

また、重要な性質として、次の大小関係が成り立ちます。

$$H(A|B) \le H(A).$$

各種エントロピー間の関係式

前回の記事も含めて3種類のエントロピーを定義したので、それぞれの間にある関係式を見ていきます。まず紹介するのは $H(A, B) = H(B) + H(A|B)$です。情報理論のエッセンスだと、ベン図みたいなのを描いてイメージで説明されていますが、計算すると次ようになります。

まず定義から次のようになります。

$$H(A, B) = – \sum_{A}\sum_{B}P(a, b)\log{P(a, b)}.$$

ここで、$P(a, b) = P(a | b) P(b)$であることと、$\log$内の積は和に分けれるので、次のようになります。

$$H(A, B) = – \sum_{A}\sum_{B}P(a, b)\log{P(a|b)} – \sum_{A}\sum_{B}P(a, b)\log{P(b)}$$

前半は$H(A|B)$の定義が出てきましたね。後半は、$\sum_{A}P(a, b) = P(b)$であることに注意すると、次のようになります。

$$H(A, B)= H(A| B) – \sum_{B}P(b)\log{P(b)} = H(A|B) + H(B)$$

$a, b$ 入れ替えて、$P(a, b) = P(b | a) P(a)$を使うと、全く同じようにして、$H(A, B) = H(A) + H(B|A)$ も示せます。

以上で、

$$H(A, B) = H(A) + H(B|A) = H(B) + H(A|B)$$

が示せました。これの中辺と右辺に着目して移行すると次も言えます。

$$H(A) – H(A|B) = H(B) – H(B|A).$$

また、左辺と右辺に着目し、$H(A, B)$と$H(A|B)$をそれぞれ移行して両辺に$H(A)$を足すとつ次の式が出てきます。

$$H(A) – H(A|B) = H(A) + H(B) – H(A, B).$$

左辺と中辺に着目して、$H(B|A)$を移行して両辺から$H(A|B)$を引くと、次の式が出てきます。
$$H(A) – H(A|B) = H(A, B) – H(A|B) – H(B|A).$$

以上をまとめると、次の4つの値は違いに等しいと言えます。
$H(A) – H(A|B)$
$H(B) – H(B|A)$
$H(A) + H(B) – H(A, B)$
$H(A, B) – H(A|B) – H(B|A)$

これを相互情報量と呼ぶのですが、長くなってきたので次の記事で詳しく説明します。

まとめ

今回の記事では結合エントロピーと条件付きエントロピーを紹介しました。

結合エントロピーの方は多次元へのシンプルな拡張というイメージでしたが、条件付きエントロピーはすでに何かしらの情報を得てる状況で新たに何かを知ったらどれほどの情報を得られるかを示すものでありなかなか重要な概念です。

また、結合エントロピーの方も重要ではないというわけではありません。実際にプログラム等で値を計算をするときは、条件付きエントロピーを直接算出する関数が主要ライブラリで提供されいていないという事情により、頻繁に算出することになります。

平均情報量(エントロピー)の定義について

はじめに

データサイエンスや機械学習関連の本を読んでいると時々エントロピーという概念に出逢います。例えば、決定木の分類の品質の測定などはそうですね。scikit-learnでは引数でジニ不純度とエントロピー、ログロスの中から選べるので必須でも無いのですが。

個人的な話ですが、このエントロピーは定義がいまいちしっくりこなかったのであまり使ってきませんでした。scikit-learnの決定木もcriterionのデフォルトは”gini”だし、こちらの方が扱いやすく感じていたというのもあります。

ただ、最近平田先生の「情報理論のエッセンス」という本を読みましてこのエントロピーについてだいぶ理解が深まったので軽くまとめておきます。

平均情報量の定義

この記事では離散な事象の場合を取り扱います。最初に平均情報量(エントロピー)の定義を書いておきます。

事象の集合$A=\{a_k\,|\,k=1, 2, \dots, n \}$の各事象$a_k$に対して、その事象が発生する確率を$P(a_k)$とすると、平均情報量$H(A)$は次のように定義されます。

$$H(A) = -\sum_{k=1}^nP(a_k)\log{P(a_k)}.$$

この平均情報量は、式の形が熱力学等のエントロピーと同じなので、情報理論においてもエントロピーと呼ばれています。

この記事では、平均情報量がなぜこのように定義されるのかというのを見ていきます。

熱力学の便利な式を適当に持ってきたわけでもなく、何となく$\log$を使っているわけでもなくきちんと背景があってこのように定義されたのだ、というのが伝われば幸いです。

情報量の数値化について

そもそものモチベーションは情報を数値化することにあります。何かの事象を観測したり、ニュースを聞いたりした時にその結果や内容がどれだけ「予想外」もしくは「驚き」であったかを数値化したものです。

先述の情報理論のエッセンスでは、次の二つのニュースが例として挙げられていました。
(a) 東京に雪が降りました。
(b) 北極に雪が降りました。

(b)の方はありふれた話であるのに対して、(a)の方は珍しいことであって(a)のニュースの方がニュース価値が大きいものであると考えられます。

このニュースとしての価値を定量的に定義しようというのが、そもそもの情報量の目的です。

情報量が満たすべき性質とそれを満たす式

ここから、具体的に事象$x$の情報量$i(x)$について考えていきますが、そのためにこれがどんな性質を持つべきなのか見ていきます。

まず考えておかないといけないのは、受け取り手が得られる情報としての価値にはその人の主観が入り得ることです。東京の天気には一切興味がなく、北極にはすごく関心があるという人にとっては(b)のニュースの方が価値があるかも知れません。これは「主観的立場」による情報の評価ですが、情報理論で扱う情報量はそうではなく、誰でも同じように評価できるようにするために「客観的立場」で情報を評価します。

客観的に評価するために、情報量を定義する際はその内容ではなく、そのニュースが発生する確率を元に価値を評価します。そのため、「情報量は確率の関数である」というのが一つ目の条件です。つまり次のように書けます。

$$i(x) = i(P(x)).$$

そして、起こりにくい事象ほどそれについて知った時の情報価値は大きいということから、$P(a)<P(b)$ならば$i(a) > i(b)$となっていることが求められます。つまり、二つ目の条件は次のようにいえます。

$i(P(x))$は$P(x)$の減少関数である。

この時点では減少関数なんていくらでもあるので情報量がどうとでも定義できてしまいます。

そこで最後に、情報の加法性という性質を要求します。これは独立した二つの情報を知った時に、その情報を同時に知った場合と、順番に知った場合、またその逆順に知った場合で得られる情報の量は同じである、ということです。

例えば、トランプを1枚引いて、以下のように確認した場合に最終的に得られる情報は全部一緒ということです。
– そのカードはスペードの10だった。
– そのカードの数字は10だった。そしてマークはスペードだった。
– そのカードのマークはスペードだった。そして数字は10だった。

スペードだったという情報を$a$、 数字が10だったという情報を$b$とすると、
$i(a\land b) = i(a) + i(b) = i(b) + i(a)$ であって欲しいということです。二つの情報が独立である場合、$P(a, b) = P(a) P(b)$ですから、この性質は次のように書くことができ、これが三つ目の条件です。

$$i(P(x)P(y)) = i(P(x)) + i(P(y)).$$

これを満足する関数を考えると、対数関数しかなくなります。

先に出てる条件で減少関数としないといけませんから、底を$s$として次のようになります。

$$i(x) = i(P(x)) = -\log_s{P(x)}$$

底の$s$としては$2$,$e$,$10$など考えられますが、情報理論においては$s=2$が使われます。ここから底は省略して$2$とします。

コインの裏表やスイッチのON/OFFなど、$1/2$の確率で得られる情報の情報量がちょうど$1$になります。また、6面のサイコロを転がして何か目が出たら、そこから得られる情報量は$\log{6}$となります。

平均情報量の定義

ここまでで、何かニュースを得たり事象を観測したらどの程度の情報が得られるかを定量化することができました。

次に、そのニュースを得る前に、そのニュースを得たらどの程度の情報量が得られるか、の期待値をを考えます。つまり、先述の例で言えば、「東京に雪が降るかどうか」を確認したらどの程度の情報量が得られるのかの期待値を定量化しようという話です。

これは、各事象ごとにその事象が発生する確率と、得られる情報の積をとって足し合わせることで得られます。通常の期待値の定義ですね。つまり平均情報量は次のようになります。

$$\begin{align}H(A) &= \sum_{k=1}^n i(a_k)P(a_k)\\
&=-\sum_{k=1}^n P(a_k)\log {P(a_k)}
\end{align}$$

これでこの記事の最初にあげた定義式が出てきました。

元々$-p\log{p}$ってなんだ?と思ってたのですが、導出を追いかけてみるとこれしか無いって感じる自然な定義ですね。

平均情報量の性質

基本的な性質を一つ紹介しておきます。平均情報量(エントロピー)$H(A)$は次の関係を満たします。

$$0 \le H(a) \le \log{n}.$$

左側の等号は $^{\exists}l \; P(a_l) = 1, k\neq l \Rightarrow P(a_k)=0$ の時に成立します。1種類の結果しか生じ得ない実験をやっても得られる情報量は0であるってことですね。

右側の統合は、$^\forall k P(a_k)=\frac{1}{n}$の時に成立します。これは発生しうる$n$種類の結果が全て等しい確率で起こりうる場合ですね。

平均情報量のもう一つの見方

特にエントロピーと呼ばれる時はそうなのですが、平均情報量は「不確実性の尺度」として理解されることも多くあります。熱力学ではそうですね。

不確実性と得られる情報量では全く逆のものに見えるのですが、これが同一の式で定義されているのは、「得られる情報量」を「不確実性の減少量」と考えられるからです。

ある実験をする前に、この事象にはこれだけ不確実性があるぞ、と評価してるのが熱力学等のエントロピーで、事象を実際に観測してこれだけの情報が得られたと言っているのが情報理論のエントロピーって感じですかね。

まとめ

ここまでで、情報の量を定式化したいというモチベーションから、定式化するとしたらどんな性質を持たないといけないか、そしてどのような式で定義できるかを見てきました。

平均情報量(エントロピー)の定義にについての話は以上になります。次回以降の記事でもう少し発展させて条件付きエントロピーや相互情報量について取り扱っていこうと思います。

PythonでYAMLファイルを読み書きする

自分はあまり使ってこなかったのですが、構造化したデータを保存するのに利用されるYAMLというデータ形式が存在します。いかにもPythonと相性が良さそうなフォーマットです。
参考: YAML – Wikipedia

最近、そこそこ大きいYAMLファイルの総チェックを行う必要があったので、Pythonでスクリプトを書いてチャチャっとやりました。その時のYAMLの読み込み方法のメモと、ついでに逆にデータをYAMLに書き出す方法を残しておきます。

サンプルとしては、次の内容が保存した sample.yaml というファイルを使います。

name: John Doe
age: 30
address:
  city: Tokyo
  country: Japan

必要なライブラリはいくつかありますがPyYAMLが代表的です。インストール時と利用時で名前が違うので注意してください。
参考: PyYAML · PyPI

# インストールコマンド
$ pip install PyYAML

# Pythonでインポートする時
>>> import yaml

それではやっていきましょう。まずはYAMLファイルの読み込みからです。

YAMLを読み込むメソッドは、load(), load_all(), safe_load(), safe_load_all() と4つも用意されています。普通はsafe_load()を使えば良いです。safeがついてない2メソッドにはセキュリティ面のリスクもあるので使用を避けることを推奨します。

import yaml


with open("sample.yaml", "r") as f:
    data = yaml.safe_load(f)

print(data)
# {'name': 'John Doe', 'age': 30, 'address': {'city': 'Tokyo', 'country': 'Japan'}}

簡単ですね。気をつけないといけないのは、safe_load()にファイルパスを渡しても読み込んではくれず、openでファイルを開いて、それを渡すって点です。

YAMLファイルは、”—“というセパレーターを使うことで単一ファイルの中に複数のYAMLオブジェクトを記述できますが、そのようなファイルを読み込むときはsafe_load_all()の方を使うので注意してください。読み込んだ結果はジェネレーターで返ってきます。

ファイルにするが手間だったのでセパレーターで区切られたYAML文字列を直接作ってやって見ました。

yaml_data = yaml.safe_load_all("""name: John Doe
age: 30
---
name: Jane Smith
age: 25
---
name: Bob Johnson
age: 40""".strip())

for y in yaml_data:
    print(y)
"""
{'name': 'John Doe', 'age': 30}
{'name': 'Jane Smith', 'age': 25}
{'name': 'Bob Johnson', 'age': 40}
"""

以上が、YAMLの読み込みです。

次は書き出しです。最初のコード例の、data変数をYAML形式ファイルに保存してみます。

保存には yaml.safe_dump() というメソッドを使います。これにPythonのオブジェクトを渡すとYAMLのテキストに変換してくれます。

print(yaml.safe_dump(data))
"""
address:
  city: Tokyo
  country: Japan
age: 30
name: John Doe
"""

ただし、普通はファイルに保存することになると思いますので、次のように使うことになると思います。

with open("output.yaml", "w") as f:
    yaml.safe_dump(data, f)

これで先ほどのテキストが、”output.yaml”ファイルに保存されます。

YAMLには一般的なブロックスタイルと、JSONに近い見た目のフロースタイルという形式がありますが、defalut_flow_style という引数をTrueにすると、フロースタイルで出力できます。ただ、これは使うことあまり無いかな。

また、インデントの数はindentという引数で指定できます。デフォルトは2ですね。

以上がPythonを用いたYAMLファイルの読み書き方法でした。

当事者になって分かったストックオプションに関してあまり理解されていないと思ったこと

はじめに

今回の記事はストックオプションに関するものです。全く技術的な要素が無く普段の投稿と違うテイストの記事ですが、このブログの訪問者にはベンチャー企業に勤めてるかたが多いんじゃないか、つまり訪問者の方にとって何かしら有益な情報になるのではないかと思ってここに書きます。

個人的な話ですが、僕は株式に限っても10年以上トレードをやっており、さらに株式以外のFX等を含めると18年近くトレーダーをやっています。さらに初めてベンチャーに転職した頃にストックオプションとは?といった趣旨のWeb記事などをよく読んでいたのでそこそこ理解しているつもりでいました。しかしそれでも実際に前職でストックオプションを付与され無事に会社がIPOして行使できた過程の中で初めて理解したことがいくつかあったのでそれをまとめます。また、ついでに一般的なSOの説明記事等で取り上げられていない、特に新卒就職からずっとベンチャーで働いてる人たちにはあまり知られてないんじゃないかなって思ったことも書いていきます。

逆に、そもそもストックオプションとは何かとか上場とは何かみたいな話は書きませんのでその辺は世にたくさんある他の記事を参照してください。

免責事項

記事の内容は正確なものになるように努めますが、自分はストックオプションの専門家ではなく、1社で行使を経験しただけなのでこの記事の内容に基づいた判断や行動は自己責任でお願いします。本編の最初に書きますがストックオプションにはざっくり分けても多くの種類があり、各企業や主幹事証券会社の設計によって事情が変わりえます。疑問に思ったことはご自身の勤め先の担当者に確認してください。

参考にこの記事を書いている僕が経験したSOは無償SOで税制適格SOです。その他の種類のSOについては未経験なので、この記事では言及しません。また、主幹証券会社は野村證券でした。証券会社による差もあると思いますので注意してください。

それでは以下本文です。

1. ストックオプションには複数の種類があり、会社ごとにも細かい違いがある

これは比較的よく知られていることですね。最近信託SOというのも話題になっていますが、一口にストックオプションと言っても多くの種類が存在します。有償SOと無償SOや、税制適格SOとそうでないもの、信託型SOとそうでないものなど、ざっくり分けても複数種類があります。

さらに細かく言えば、ストックオプションは会社ごとに設計されるものであり、行使条件等が会社によって細々と違います。例えば在籍年数が一定を越えないと全部は行使できないとか退職後の行使を認めるかどうかとかの規約が会社によって違います。同じ会社であっても個人個人でロックアップの有無などが変わったりもします。

免責事項に書いたことの繰り返しですがご自身が付与されたSOの設計がどうなっているのかはよく確認する必要があります。

また、これらの事情から個々の会社ごとの差異を除いたSOの一般論として語れる内容には限界があり、世の中のSOに関する各記事に説明不十分な点が出てしまうのも仕方ないと思いました。

2. ストックオプションの権利行使と取得した株式の売却はそれぞれ別の手続き

世の中のWeb記事のざっくりとした解説では「ストックオプションとは決められたか価格で株を買う権利であり、その価格より株価が高い状態で行使すると差額分を利益として得られる。」と言った解説がされています。要するに「行使価格100円のSOを株価300円の時に行使したら一株当たり200円の利益を得られる」と言った趣旨のことが書いてあります。

これで僕は「SOを行使してその株を売る」という1個の手続きが存在するのかな、と勘違いしていました。要するに「SO行使します」と言ったら(行使価格と時価の差分)*(株数)の行使益みたいなものがポンと振り込まれるのかと思っていました。

実際は、行使は行使、売却は売却で別々の手続きです。まずSOを行使して株を買って、その後保有し続けるか売却するかという話になります。

3. ストックオプションを行使するときはお金を払う

2.の続きですね。行使と売却が同時にできず、まずSOを行使するだけという手続きがあるので当然の話ですが、株を買う権利を行使するならその購入費用を支払わないといけません。

自分が100万円分のSOを行使したいなら100万円払う必要がありますし、1000万円分のSOを行使したいなら1000万円、1億円分なら1億円を指定口座に振り込む必要があります。

これ読んで心配になった人もいるかと思いますが、SOの一部を行使して、その株を売って、それで得た資金でまたSOを行使して、と繰り返すことも可能なので自己資金がなくてSOが無駄になるってことはあまりないと思います。ただ、後に書きますが、株をなかなか売れない人もいるのでこの手段は全員がスムーズに使えるわけでは無いのでその点は注意です。

4. 税制適格ストックオプションは年間の行使額に上限がある

税制適格ストックオプションは税率が優遇される代わりに色々制限がつきますが、その中で社員が特に気にしないといけないのがこの点です。2023年時点では税制適格ストックオプションの年間の行使価格の上限が1200万円となっています。

もう少し細かくいうと、租税特別措置法 第29条の2というのがあって、「二 当該新株予約権の行使に係る権利行使価額の年間の合計額が、1200万円を超えないこと。」と定められています。

ただ、これは税制上の優遇を受けられる法律上の上限行使額が年間1200万ってことで、各企業ごとにうちは上限x万円までしか行使できないとか別途規約があったり、税制上の優遇がされなくていいならいくらでも行使していいとか個々に規定がある可能性もあるのでよく確認してください。

これが大きく影響するのは多額のSOを条件に招致される役員さん等でしょうね。

5. ストックオプションの行使には証券会社に専用口座が必要

これは主幹証券会社によって違うかもしれません。僕が経験したのが野村證券だけなのでそれを例に書きます。

実は、新卒で入った会社を辞める時に持株会の株を売るために野村證券の口座を作りました。その後は手数料が高いので使わず、解約手続きも電話が繋がらずできなかったのでただ放置していた口座でした。SOを付与された当時の勤め先の主幹証券会社が野村證券になった時にじゃあこの口座を使えるかと思っていたのですが、ストックオプションの取引にはストックオプション専用講座が必要ということになり野村證券に改めてもう一口座開設の手続きが必要でした。

一般的に自分が複数の証券会社に口座を持っている場合、口座間で株式の移管が行えるものなのですが、税制適格ストックオプションの税制上の優遇を受けるためには他の証券会社に移さず、この専用講座で売却する必要があります。

野村證券なので売却手数料は馬鹿みたいに高かったです。普段ネット証券を使っている方は驚くと思います。ただ逆にSOを行使して株式を購入する時は手数料はなく、純粋に行使価格と株数を掛けた金額をちょうどが支払い金額でした。この辺も主幹証券会社によって事情が変わると思います。

6. ストックオプションの行使には時間がかかる

これはタイトルそのままです。会社や主幹証券会社によって日数は少々変わると思いますが。SOを行使したい旨を会社に伝えて申請書をもらって書いて提出して承認してもらって行使資金の振込先口座を教えてもらってお金を振り込んでそこから手続きが進んで、自分の証券口座に株式が移されるという手順を順に踏んでいくことになり、1個1個の手順が数日がかりになります。

通常の株の購入みたいに今日買いたいと思ってもすぐできるものでは無いということです。

7. ストックオプションを行使して得た株は自由に売れるわけでは無い

これはストックオプションに限った話ではなく、上場企業社員の一般論です。通常は、上場企業の社員は自分が勤める会社の株を自由に売り買いできず、会社ごとに何かしらの規約があります。

また、IPO固有の事情としてはロックアップというものがあって、一部の大株主や役員は新規上場から一定期間該当株式の売買ができない取り決めになります。

僕はこのロックアップの対象外だったので上場直後から複数の知人から「ロックアップついてなかったから、もういつでも売れるんじゃ無いですか?売りました?」と言った趣旨のことを言われました。しかし、ロックアップがなかったからと言って自由には売れません。

これは本当に会社ごとに規約が違うので自社に確認するべきですが、売買できる期間が決まっていて売買を希望するなら事前に申請が必要とかそういう種類のルールがあると思います。非上場企業の場合、現時点でルールがなかったとしてもおそらくIPOする時にこの種のルールが作られると思います。

また、退職したとしても一定期間はその企業の関係者扱いになります。これが法律で決まってるのか各企業の自主的な規制なのかと言った詳しいことは知りませんが、自分が新卒時就職した上場企業も退職後1年間は証券口座上で関係者扱いだったのでおそらく一般的なルールなのでしょう。

8. 実はストックオプションはノーリスクでは無い

ここまでに買いた2, 6, 7 の組み合わせから導かれる結論がこれです。なんか世の中には株価が下がったらストックオプションを行使しなければいいだけだからノーリスクだという主張をする記事があります。実は僕はそれを信じていたので2.の行使と売却が同時にできるのではという勘違いをしていました。

ストックオプションの行使と株式の売却が別の手続きで、それぞれがタイムリーに行えない以上、ストックオプションを行使してから売るまでに株価が行使価格を下回って損をするリスクはあります。また、行使だけに限ってももう取り消しできないという段階で株価が暴落し、市場価格より高い金額で購入することになるリスクはあります。

9. ストックオプションの保有状況はIPO時に公表される

実は当事者になって一番びっくりしたのがこれです。日本取引所グループのサイトに新規上場企業の情報がまとまったページがあります。ここのPDFを見ていくと各企業の株主の情報が載っているのですが、現物株を持ってる人だけでなくストックオプションの保有者も公開されます。公開されるのは氏名と住所(市や区まで)と株数ですね。

参考: 新規上場会社情報 | 日本取引所グループ

7. で知人にばれた元ネタはこれと、これを転載したIPO情報サイトです。

10. ストックオプション専用口座で発生する利益にかかる税金は源泉徴収では無い

これはもしかしたら証券会社によって違うかもしれません。少なくとも僕が使っているところでは特定口座と扱いが異なり、売却益からはまだ税金が取られていないので時期が来たら確定申告して自分で納税することになります。これ自体は今後やることなのでこれ以上語れることはありません。

11. なかなか株を売れない人もいるらしい

これは自分はそうではなかったので伝聞だけの話です。会社の中でも特に要職にある人はそう気軽に株式を売却できないらしいですね。こうなると3. で書いたテクニックのSOを行使してその株の売却資金で次のSOを行使するって手段が使えなくなります。

また、7. の上場企業の従業員は自由に売買できないって話にもつながりますが、インサイダー情報を知ってる人という扱いになると売買を申請しても却下される可能性があるとも聞きました。

これは会社だけでなく個人個人によって状況が大きく異なる点なので各自がよく確認してください。

12. 売却時期の判断は難しい

これはただの感想。実際難しいです。個人的にはちょっとしくじったと思ってますがもう仕方ないですね。個人投資家としてのキャリアの中でもこんな大きなポジションを持ったことはなかったですし、さらに自分は新規上場株を扱ってこなかったので、これまでの取引経験が活かせたような実感はありませんでした。

まとめ

株取引をやっているので自分は詳しい方だと思っていましたが、それでもストックオプションに関して誤解していることが複数ありました。人生でそう何度も経験することではなく、当事者のほとんどが情報不足で直面するわりにインパクトが大きいことなので、この記事が訪問者の方々のキャリアの中でなにかしらお役に立てれば幸いです。

免責事項の中で書いたことの繰り返しになりますが僕自身も1回経験しただけで、ストックオプションは他にも種類があるため、他の種類のストックオプションにはそれはそれで固有の事情があると思います。ベンチャーで働く皆様におかれましては各社の制度をよく理解し良いベンチャーライフをお送りください。

Jupyter(ipython)のマジックコマンドを自作する

Jupyterには便利なマジックコマンド(%や%%を付けて呼び出すアレです)がたくさんありますが、あれを自作する方法を紹介します。

ドキュメントは IPythonのドキュメントのこちらを参照します。
参考: Defining custom magics — IPython 8.14.0 documentation

簡単な方法は、register_line_magic, register_cell_magic, register_line_cell_magic の3種のデコレーターをマジックコマンドとして使いたい関数につけることです。

register_line_magicはその行の文字列を格納する引数を1個だけ、register_cell_magicとregister_line_cell_magicは、マジックコマンドと同じ行の文字列を格納する引数と、セル内の文字列を格納する引数の2個をもちます。

ざっと、受け取った文字列をprintするだけのコマンドを作ってみましょう。3種類それぞれのサンプルです。

from IPython.core.magic import register_line_magic
from IPython.core.magic import register_cell_magic
from IPython.core.magic import register_line_cell_magic


@register_line_magic
def line_magic(line):
    print(line)


@register_cell_magic
def cell_magic(line, cell):
    print(f"line: {line}")
    print(f"cell:\n{cell}")


@register_line_cell_magic
def line_cell_magic(line, cell=None):
    print(f"line: {line}")
    if cell:
        print(f"cell:\n{cell}")

順番に使ってみます。

%line_magic ラインマジックテスト
print()
# 以下出力
# ラインマジックテスト
%%cell_magic セルマジックと同じ行のテキスト
セルマジック内のテキスト
その2行目

# 以下出力。
"""
line: セルマジックと同じ行のテキスト
cell:
セルマジック内のテキスト
その2行目
"""
%line_cell_magic ラインマジックとして動作させた場合

# 以下出力
# line: ラインマジックとして動作させた場合
%%line_cell_magic セルマジックとして動作させた場合。
セルの中身

# 以下出力
"""
line: セルマジックとして動作させた場合。
cell:
セルの中身
"""

めっちゃ簡単ですね。

最初のマジックコマンドを定義したコードをPythonファイルとして保存して、import可能なディレクトリに置いておくと、インポートして使うこともできる様になります。例えば、 my_magic.py というファイル名で保存しておけば次の様に使えます。

import my_magic


%line_magic 読み込んだモジュールのマジックコマンドが使える
# 読み込んだモジュールのマジックコマンドが使える

my_magic.line_magic("普通の関数としても呼び出せる")
# 普通の関数としても呼び出せる

さて、通常マジックコマンドをライブラリ等から読み込んで使う場合、この様にimport するのではなく、%load_ext して使うことが多いと思います。これは、先ほどあげたドキュメントのページでベストプラクティスとされているのがその方式だからです。@register_* のデコレーターで直接登録する上記の方法は推奨されてないんですね。

その代わりにどうするかというと、 load_ipython_extension というメソッドを持つpythonファイルを作り、このメソッドの中で定義した関数たちを register_magic_function でマジックコマンドへ登録していきます。

引数は順に、登録する関数本体、コマンドの種類(省略したら’line’)、マジックコマンドとして呼び出す時の名前(省略したら元の関数名)です。

例えば、 my_ext.py というファイルを作りその中を次の様にします。

def load_ipython_extension(ipython):
    ipython.register_magic_function(
        line,
        magic_kind='line',
        magic_name='line_magic'
    )

    ipython.register_magic_function(
        cell,
        magic_kind='cell',
        magic_name='cell_magic'
    )

    ipython.register_magic_function(
        line_cell,
        magic_kind='line_cell',
        magic_name='line_cell_magic'
    )


def line(line):
    print(line)


def cell(line, cell):
    print(f"line: {line}")
    print(f"cell:\n{cell}")


def line_cell(line, cell=None):
    print(f"line: {line}")
    if cell:
        print(f"cell:\n{cell}")

各メソッドそれぞれにはデコレーターはつきません。

この様なファイルを用意すると、load_ext で読み込んだ時に、load_ipython_extension が実行されて、その中でマジックコマンドの登録が行われます。結果、次の様に使えます。

%load_ext my_ext


%line_magic ロードしたマジックコマンドが使えた
# ロードしたマジックコマンドが使えた

先ほどのimportした場合との挙動の違いとしては、これは明示的にマジックコマンドの読み込みだけを行っているので、各メソッドはインポートはされておらず、個々のメソッドの、line, cell, line_cell は名前空間に登録されてないということです。(マジックコマンドとして登録された、line_magic, cell_magic, line_cell_magic の名前でなら通常のメソッドと同じ様に使うことも可能です)

以上が簡単なマジックコマンドの作り方になります。

lru_cacheによるメモ化をクラスのメソッドに使うとメモリリークを引き起こすことがある

もう結構前なのですが、メモ化というテクニックを紹介しました。
参考: pythonの関数をメモ化する

これは@functools.lru_cacheというデコレーターを使って、関数の戻り値を記録しておいて何度も同じ関数を実行するコストを削減するのでしたね。計算コストが削減される代わりに、結果を保存しておく分メモリを消費します。

僕はこれを結構使ってたのですが、最近、これをクラスのメソッドに対して利用しているとメモリリークを引き起こすことがあるという気になる情報を得ました。ブログで紹介しちゃった責任もあるので、今回はその問題について調べました。

この問題は何箇所かで指摘されていて、一例を挙げるとこのissueなどがあります。
参考: functools.lru_cache keeps objects alive forever · Issue #64058 · python/cpython

こっちのYoutubeでも話されていますね。
参考: don’t lru_cache methods! (intermediate) anthony explains #382 – YouTube

具体的に説明していくために超単純なクラスを作って実験していきましょう。
まず、そのまま返すメソッドを持ってるだけのシンプルクラスを作ります。そして、このクラスがメモリから解放されたことを確認できる様に、デストラクターが呼び出されたらメッセージを表示する様にしておきます。これをインスタンス化して関数を1回使って、delで破棄します。

class sample1:
    def __init__(self, name):
        self.name = name

    def __del__(self):
        print(f"インスタンス: {self.name} を破棄しました。")

    def identity(self, x):
        return x


a = sample1("a")
print(a.identity(5))
# 5
del a
# インスタンス: a を破棄しました。

デストラクターがちゃんと呼び出されていますね。

これが、メソッドがメモ化されていたらどうなるのかやってみます。

from functools import lru_cache


class sample2:
    def __init__(self, name):
        self.name = name

    def __del__(self):
        print(f"インスタンス: {self.name} を破棄しました。")

    @lru_cache(maxsize=None)
    def identity(self, x):
        return x


b = sample2("b")
print(b.identity(5))
# 5
del b
# 何も表示されない。

今度はガベージコレクターが動きませんでしたね。これは、変数bを削除したことによって変数bからの参照は消えたのですが、メソッドのidentity の一つ目の引数がそのインスタンス自体をとっていて、これを含めてキャッシュしているので、キャッシュがインスタンス自身への参照を保存しているためガベージコレクションの対象にならなかったのです。そのため、インスタンスbが確保していたメモリは解放されず、占拠されたままになります。

ちなみに、循環参照の状態なので、明示的にガベージコレクターを動かすと消えます。

import gc


gc.collect()
# インスタンス: b を破棄しました。

ちなみに、最初のキャッシュが発生した時に循環参照が生まれているのでメモ化したメソッドを一回も使わなかったら普通に消えます。

c = sample2("c")
del c
# インスタンス: c を破棄しました。

以上の様な問題があるので、クラスメソッドで lru_casheを使う時は気をつけて使うことをお勧めします。

とはいえ、最近のMacBookくらいのメモリ量であれば、インスタンスが何個か過剰に残ったとしてそれでメモリが枯渇する様なことはないんじゃないかなとも思います。仮にメモリがピンチになる様な使い方をしていたとしても、maxsizeを適切に設定してメモリサイズを押さえておくとか、明示的にgc/collect()動かすとかの対応が取れるかと。

僕としては、メモリが解放されないことよりも、デストラクターが動かなくてそこに仕込んだ後始末形の処理が動かないのが気になりましたね。