Random projection について

前回の記事で紹介した、Johnson–Lindenstraussの補題を理論的背景に持つ次元削減の手法として、
Random projection と呼ばれるものがあります。
参考: Random projection – Wikipedia

手法は至ってシンプルで、乱数で生成した行列を掛けるによってデータを低次元へと埋め込んでしまうようです。

補題が主張する、距離を維持できる線形写像があるぞって話と、
ランダムに生成した行列で定義される線型写像で距離が維持できるぞと言う話は、
かなり論理にギャップがあるように感じ、その間のロジックをまだ正確には追えていないのですが、
scikit-learnに実装があるのでまずは試してみることにしました。

2000次元のデータを1000件用意し、Gaussian Random Projection という要するに正規分布で生成した行列で低次元にうつす手法を使い、
距離が保たれていることを確認します。

まずはデータの生成です。


import numpy as np

X = np.random.randn(1000, 2000)*10
print(X.shape)
# (1000, 2000)

続いて、Gaussian Random Projection のモデルのインスタンスを生成して、学習、変換します。
ハイパーパラメーター$\varepsilon$は$0.5$としました。
ドキュメントによると、次元削減後の次元は
$n\_components >= 4 \log(n\_samples) / (eps^2 / 2 – eps^3 / 3)$
となるようです。


print(4*np.log(len(X))/(eps**2/2-eps**3/3))
# 331.5722533911425

ですから、 332次元に圧縮されるかと思いきや、結果は331次元になります。
(ドキュメントのミスなのかバグなのか不明)


from sklearn.random_projection import GaussianRandomProjection


eps = 0.5
grp = GaussianRandomProjection(eps=eps)
grp.fit(X)
X_new = grp.transform(X)
print(X_new.shape)
# (1000, 331)

これで、元々2000次元だったデータを331次元に圧縮することができました。
それも、331×2000次元のランダム行列を掛けただけという、超単純な方法でです。

ちなみにその行列自体は grp.components_で取得できます。


print(grp.components_.shape)
# (331, 2000)
print(grp.components_.mean())
# -7.730060662586934e-05
print(grp.components_.var())
# 0.0030132642015398016

要素の平均はほぼ$0$であり、分散は$1/331=0.0030211…$に近い値になっており、ドキュメント通りであることが確認できます。

さて、最後に距離がある程度保存できていることを確認しておきましょう。
元のデータ X と、変換後のデータX_new それぞれについて距離行列を算出します。
そして、比率(変化率)を算出しその最小最大を見てみましょう。


X_pdist = pdist(X)
X_new_pdist = pdist(X_new)

print((X_new_pdist/X_pdist).min(), (X_new_pdist/X_pdist).max())
# 0.8230255694414008 1.18365715425175

eps が 0.5 だったので、 0.5倍〜1.5倍の範囲に収まる想定だったのですが、それよりもずっと良い精度で距離を維持できていることがわかりました。
今回は元のデータが正規分布だったので簡単だったのかもしれませんね。

Johnson–Lindenstraussの補題

『Pythonではじめる教師なし学習 ―機械学習の可能性を広げるラベルなしデータの利用』
という本の中で、「Johnson–Lindenstraussの補題」と言うものを知ったのでその紹介です。

これは、(高次元の)ユークリッド空間内の要素をそれぞれの要素間の距離をある程度保ったまま、
別の(低次元の)ユークリッド空間へ線型写像で移せることを主張するものです。

英語版のWikipediaに主張があるので、それを日本語訳しました。
Johnson–Lindenstrauss lemma – Wikipedia

$\varepsilon$を$0 < \varepsilon < 1$とし、$X$を $\mathbb{R}^N$内の$m$点の集合とします。さらに、$n$が$n > 8\log(m)/\varepsilon^2$を満たすとします。
すると、線型写像 $f:\mathbb{R}^N \longrightarrow \mathbb{R}^n$であって、
任意の$u, v \in X$ に対して、
$$
(1-\varepsilon)\|u-v\|^2 \leq \|f(u)-f(v)\|^2 \leq (1+\varepsilon)\|u-v\|^2
$$
を満たすものが存在する。

僕の個人的な感想ですが、この補題のすごいところは$n$の条件に元の次元$N$が含まれていないことです。
$N$が非常に大きな数だった場合に、要素間の距離をそこそこ保ったままはるかに小さな次元に埋め込める可能性を秘めています。

その一方で、$N$が小さく、要素数$m$が極端に大きい場合は、次元削減としての効果はあまり得られません。

matplotlibのメモリラベルテキストの回転だけをオブジェクト指向インターフェースで行いたい

主にX軸のメモリが日付などの場合に発生するのですが、matplotlibのメモリテキストが重なって読めなくなることがあります。
それをテキストを回転させて読めるようにする方法のまとめです。

とりあえず、プロットするデータを生成しておきます。


import matplotlib.pyplot as plt
from datetime import datetime, timedelta
import numpy as np
import pandas as pd

# 日付のリスト生成
dates = [datetime(2021, 1, 1) + timedelta(days=i) for i in range(10)]
date_str_list = [d.strftime("%Y-%m-%d") for d in dates]
# ランダムウォークデータ作成
data = np.cumsum(np.random.randn(10))

他サイトなどを見ると、よく用いられているのは、
plt.xticks(rotation=角度)
です。
結果だけ見れば正直これで十分なのですが、
これでも確かに回転できますが、これは、オブジェクト指向インターフェースではなく、ここだけpyplotインターフェースになります。
個人的に、これらを混在させるのは好きではありません。


fig = plt.figure(facecolor="w")
ax = fig.add_subplot(1, 1, 1)
ax.plot(date_str_list, data)
plt.xticks(rotation=90)
plt.show()

そしてオブジェクト指向インターフェースで回転させる方法として、よく見かけるのは、
ax.set_xticklabels(xlabels, rotation=角度)
とするものです。
これでもラベルを回転できるのですが、第一引数のxlabelsが省略できず、改めて指定する必要があります。
回転だけやりたいんだというときにこの仕様は面倒です。

前置き長くなりましたが、以上の2個の方法の良いとこどりで、オブジェクト指向インターフェースで回転だけを実行する方法を調べました。
結果として以下の方法があることがわかりました。

ax.tick_params(axis="x", labelrotation=角度)
ax.xaxis.set_tick_params(rotation=角度)
引数はどちらもlabelrotation=でもrotation=でも両方対応しているようです。

それぞれコード例は以下のようになります。


fig = plt.figure(facecolor="w")
ax = fig.add_subplot(1, 1, 1)
ax.plot(date_str_list, data)
ax.tick_params(axis="x", labelrotation=90)
plt.show()

fig = plt.figure(facecolor="w")
ax = fig.add_subplot(1, 1, 1)
ax.plot(date_str_list, data)
ax.xaxis.set_tick_params(rotation=90)
plt.show()

matplotlibのdpiとfigsizeの正確な意味を調べてみた

以前、「matplotlibはdpiの数値を大きくすると画像の解像度が上がる」、みたいな非常に雑な理解で記事を書いたのですが、改めて仕様を確認したのでそのメモです。
参考: matplotlibのグラフを高解像度で保存する
前提として、「dpi」は「dots per inch」の略で1インチあたりのドット数を意味します。
正直、画面に表示される画像のサイズというのは、PCのディスプレイサイズに大きく依存するので、1インチを定義することなんてできないだろうと思って流していたのですが、
実は、 figsizeで指定する数値の単位がインチだったようです。

参考: matplotlib.figure.Figure

figsize(float, float), default: rcParams[“figure.figsize”] (default: [6.4, 4.8])
Width, height in inches.

したがって、 figsize で指定した(横幅, 縦幅) と dpi を掛け合わせると、
生成される図のピクセル数が (横幅*dpi) × (縦幅*dpi) と求まるということのようです。

デフォルトの設定(rcParamsでfigsizeとdpiは確認できる)で、figureを生成して確認してみましょう。


import matplotlib.pyplot as plt

print(plt.rcParams["figure.figsize"])
# [6.0, 4.0]
print(plt.rcParams["figure.dpi"])
# 72.0
fig = plt.figure()
# <Figure size 432x288 with 0 Axes>

ドキュメントによると figsizeのデフォルトは、[6.4, 4.8]、dpiのデフォルトは100のはずなのですが、なぜか僕の環境はそれぞれ、
[6.0, 4.0]と 72になってます。これの原因は不明ですが話の本題とズレるので置いておきます。

生成された図のサイズは、横幅が 6*72=432ピクセル、縦幅が4*72=288ピクセルと、理論通りになりました。

例えば、(滅多にそんな要件はないでしょうが)フルHDサイズのグラフ(1920×1080)が作りたかったら、
figsizeを(16, 9)、dpiを120で作成すれば良いことになります。


fig = plt.figure(figsize=(16, 9), dpi=120)
# <Figure size 1920x1080 with 0 Axes>

文字列のバイト数を取得する

Amazon Comprehend も Amazon Translate も、1度に処理できるテキストサイズの上限が文字数ではなく、バイト数で決まっています。(どちらも5000byte)
とりあえずAPIに投げてエラー処理で対応してもいいのですが、あまり無駄にAPIを叩くようなことをはしない方がいいので、
できるだけ事前にAPIに渡すテキストのサイズが上限を超えていないことを確認することが望ましいです。

そこで、Pythonの文字列の長さをバイト数で取得する方法を調べました。
結論から言うと、バイト列オブジェクトにエンコードして、その後に長さを調べると良いようです。
エンコードには、 str.encodeを使います。

以下のようにして、”走れメロス”が15バイトであることがわかりました。


title="走れメロス"
print(len(title.encode()))
# 15

Amazon Translate を試してみた

ちょっとAmazon Translateに興味が湧いて試してみたのでそのメモです。
結論ですが、非常に簡単に翻訳ができることがわかりました。

準備として、 boto3 が使えるようにしておく必要があります。(アクセスキーやシークレットキーの準備。自分は環境変数に入れています)
また、利用するIAMにAmazon Translateの権限を付与しておく必要があります。

例文はいつも通り「走れメロス」から拝借しています。

翻訳対象のテキストはこちらです。


text = """
メロスは激怒した。
必ず、かの邪智暴虐の王を除かなければならぬと決意した。
メロスには政治がわからぬ。
メロスは、村の牧人である。
笛を吹き、羊と遊んで暮して来た。
けれども邪悪に対しては、人一倍に敏感であった。
きょう未明メロスは村を出発し、野を越え山越え、十里はなれた此このシラクスの市にやって来た。
メロスには父も、母も無い。
女房も無い。
十六の、内気な妹と二人暮しだ。
"""

Amazon Translate をboto3から使う方法は非常に簡単で、client を生成して、
translate_textテキストに、ほんやくしたいテキスト、元の言語、翻訳先の言語を指定するだけです。
今回は 日本語 -> 英語 を指定しました。

ちなみにドキュメントはこちらです。


import boto3
client = boto3.client('translate')

result = client.translate_text(
    Text=text,
    SourceLanguageCode="ja",
    TargetLanguageCode="en",
)

print(result["TranslatedText"])
"""
Melos got furious.
He determined that he must exclude the king of wicked violence.
Melos does not know politics.
Melos is a village shepherd.
I played a whistle and played with the sheep.
However, for evil, people were more sensitive to evil.
Today, the unknown Melos left the village, crossed the field over the mountains, and came to this city of Silax, where the city of Silax is gone.
Melos has no father or mother.
There is no wife.
I live two with sixteen shy sisters.
"""

あっという間にできましたね。
「笛を吹き、羊と遊んで暮して来た。」の部分の主語が「I(私)」になってしまっていたり、邪悪に対して敏感なのがメロスではなく人々(people)になっていたり、
元々の文の主語が省略されていた部分については少し誤訳があるように感じますが、翻訳サービスとしてはやむを得ない部分もあるでしょう。

今回のコードでは、元の言語をSourceLanguageCode="ja"と指定しましたが、ここは”auto”とすることもできます。


result = client.translate_text(
    Text=text,
    SourceLanguageCode="auto",
    TargetLanguageCode="en",
)

この場合、 Amazon Comprehend を使って自動的に元のテキストの言語を推定して翻訳してくれます。
元の言語を何と推定したかは、結果のオブジェクトから取得できます。きちんと日本語(ja)になったようです。


print(result["SourceLanguageCode"])
"ja"

一度に翻訳できるテキストの長さは、5000byteまでです。5000文字ではないので特に僕ら日本人は注意が必要です。
試しに、元のテキスト(191文字)を20回繰り返して長文を作って翻訳にかけてみます。


long_text = text*20
print(len(long_text))
# 3820

try:
    result = client.translate_text(
        Text=long_text,
        SourceLanguageCode="ja",
        TargetLanguageCode="en",
    )
except client.exceptions.TextSizeLimitExceededException as e:
    print(e)

"""
An error occurred (TextSizeLimitExceededException) when calling the TranslateText operation:
Input text size exceeds limit.
Max length of request text allowed is 5000 bytes while in this request the text size is 11020 bytes
"""

出力されたエラーメッセージを読んでいただけるとわかりますが、5000バイトが上限なのに、11020バイト渡されたと言うエラーになっていますね。
しかし、リクエストしたテキストは3820文字です。

明らかに短いテキストを翻訳にかける場合は問題ないですが、そうでなければ、
translate_text を呼び出す前にチェックを入れるか、
上のコードみたいに例外処理を加えた方が良いでしょう。

matplotlibでグラフ枠から見た指定の位置にテキストを挿入する

matplotlibでは、 matplotlib.axes.Axes.text メソッドを指定して、グラフ中にテキストを挿入できます。
このとき、通常は、 ax.text({x座標}, {y座標}, {挿入したいテキスト}) という構文でテキストが入れられます。
ここで言う、 x座標/ y座標は それぞれx軸、y軸に対応した座標になります。当然ですね。
散布図の点や、グラフの頂点に文章を補足するときはとてもありがたい仕様ですが、
その反面、グラフの左上の方、とか中央といった、グラフの中の特定の場所にテキストを入れたい場合はちょと不便です。
それは、グラフの枠の左上や中央の座標が何になるか非自明だからです。

このニーズに対応して、x軸、y軸の座標ではなく、グラフ内部の位置でテキストの位置を指定できることがわかったので紹介します。

ドキュメントの Examples のところに 例があるのですが、transform=ax.transAxesを指定すると、ax.textのx座標、y座標はx軸y軸とは関係なくなり、
グラフの枠の左下を(0, 0), 右上を(1, 1)とする相対座標に変わります。

これを使って、グラフのすみの方や中央に簡単にテキストを置けます。

ちなみに、デフォルトでは、指定した座標の位置に、テキストの左下が当たるように配置されます。
これは、縦位置は verticalalignment{‘center’, ‘top’, ‘bottom’, ‘baseline'(デフォルト), ‘center_baseline’}、
横位置はhorizontalalignment{‘center’, ‘right’, ‘left'(デフォルト)} という引数で変更することができます。
引数名が長いので、それぞれ、va, ha というエイリアスを用意してくれています。
これらの引数は、 transform=ax.transAxes を指定しない時も同じように使えるので覚えておきましょう。

あまり面白い例でなくて恐縮ですが、色々パターンを試したコードとその出力を置いておきます。


import matplotlib.pyplot as plt
import numpy as np

x = np.linspace(-np.pi, np.pi, 101)
y = np.sin(x)


fig = plt.figure(facecolor="w")
ax = fig.add_subplot(1, 1, 1)
ax.plot(x, y)

# 通常は、指定した座標の位置にテキストが挿入される
ax.text(np.pi/2, 1, "$(\pi/2, 1)$")
ax.text(-np.pi/2, -1, "$(-\pi/2, 1)$")

# transform=ax.transAxes を指定すると、図形の枠を基準にした位置にテキストが挿入される
ax.text(0.01, 0.01, "左下", transform=ax.transAxes)
ax.text(0.01, 0.99, "左上", verticalalignment='top', transform=ax.transAxes)
ax.text(0.99, 0.01, "右下", horizontalalignment='right', transform=ax.transAxes)
ax.text(0.99, 0.99, "右上", va='top', ha='right', transform=ax.transAxes)
ax.text(0.5, 0.5, "中央", va='center', ha='center', transform=ax.transAxes)
# 0 ~ 1 の範囲を超えるとグラフの外にテキストを配置できる
ax.text(0.5, 1.05, "上部枠外", ha='center', transform=ax.transAxes)
ax.text(1.02, 0.5, "右側枠外", va='center', transform=ax.transAxes, rotation=270)

plt.show()

出力はこちらです。

transform 引数に他にどんな値を設定しうるのか探したところ、以下のページが見つかりました。
参考: Transformations Tutorial
ax.transData がデフォルトのデータにしがたった座標軸みたいですね。
ax.transAxes の他にも、fig.transFigureやfig.dpi_scale_trans を使って、図全体の中での相対位置で
テキストを配置することもできるようです。
fig.transFigure のほうが、左下が(0, 0)、右上が(1, 1) となる座標で、
fig.dpi_scale_trans はピクセル数を使った具体的な座標指定です。

matplotlibのグラフのx軸とy軸を反転する方法まとめ

先日、matplotlibで作った少し手の込んだグラフを、x軸とy軸を反転したくなることがありました。
少々手こずったのでその時調べた内容をまとめておきます。

matplotlibでは、グラフの種類によってx軸y軸の反転方法が異なります。
大きく分けて次の3パターンがあるようです。

1. x座標とy座標の引数の順番を入れ替えるだけ。
2. x軸とy軸を反転させたバージョンの別のメソッドが用意されている。
3. グラフを書くときに引数で向き(orientation)を指定する。

また、物によっては反転させることができないものもあるようです。

具体的に一つやってみます。
まずデータの準備です。


# 必要ライブラリのインポートとデータ生成
import matplotlib.pyplot as plt
from scipy.stats import beta
import numpy as np

beta_fz = beta(a=3, b=2)
data = beta_fz.rvs(100)
# 真の期待値
x_mean  = beta_fz.stats("m")

そして、ここで生成したデータを元に、以下のグラフと、そのxy軸反転版を書いてみました。
– データのヒストグラム
– 元の分布の確率密度関数
– 元の分布の期待値の位置を表す線
– 元の分布のデータの70%のデータが含まれる区間の塗り潰し


x = np.linspace(0, 1, 101)
x_clip = np.linspace(beta_fz.ppf(0.15), beta_fz.ppf(0.85), 101)

fig = plt.figure(figsize=(12, 6), facecolor="w")

ax = fig.add_subplot(1, 2, 1, title="元のグラフ")
ax.hist(data, density=True, alpha=0.3)
ax.plot(x, beta_fz.pdf(x))
ax.fill_between(x_clip, beta_fz.pdf(x_clip), alpha=0.3, color="orange")
ax.vlines(x_mean, ymin=0, ymax=beta_fz.pdf(x_mean))

ax = fig.add_subplot(1, 2, 2, title="x軸y軸反転したグラフ")
# ヒストグラムは orientation="horizontal" を指定する
ax.hist(data, density=True, orientation="horizontal",  alpha=0.3)
# plot は渡す引数の順番を入れ替えるだけ
ax.plot(beta_fz.pdf(x), x)
# fill_between に対しては、 fill_betweenx メソッドが用意されている
ax.fill_betweenx(x_clip, beta_fz.pdf(x_clip), alpha=0.3, color="orange")
# vlines に対しては、 hlines メソッドが用意されている
ax.hlines(x_mean, xmin=0, xmax=beta_fz.pdf(x_mean))

plt.show()

出力されたグラフが次です。

x軸とy軸が入れ替わりました。 コードをみていただくとそれぞれ反転方法が異なっていることがわかると思います。

x座標とy座標の引数の順番を入れ替えるだけのパターンには、
ax.plot(折れ線グラフ)やax.scatter(散布図)があります。

x軸とy軸を反転させたバージョンの別のメソッドが用意されているパターンには、
棒グラフであれば、ax.bar と ax.barh、
エリアの塗りつぶしであれば、ax.fill_between と ax.fill_betweenx、
縦線であれば、 ax.vlines と ax.hlines
などの組み合わせがあります。
ドキュメントのこちらのページで似てる名前のメソッドを探すと良いでしょう。

グラフを書くときに引数で向き(orientation)を指定するパターンは、
だいたい、ヒストグラム ax.hist だけと考えても良さそうです。
これはそれぞれのメソッドのドキュメントを読めば判断することができます。
(もしかしたら他にもあるかもしれませんが。)

一部のグラフには反転方法がわからないものもありましたが、実用上困ることはなさそうです。