これは fukuoka.ex Elixir/Phoenix Advent Calendar 2021の11日目です。昨日は @tuchiro さんの 業務でプログラムを始める入門者のために保守エンジニアの観点から読みやすいコードの構造を考えてみたでした。
はじめに
計算途中や計算結果のデータをあまり手間をかけずに可視化したいときってありますよね。チャチャっとグラフやら表やらにしたくなること、多くないですか。でもじゃあ Phoenix/LiveView でって言いたいところですけど、ササッとやるには LiveView でも大変そうじゃありませんか。今回はヒートマップをチョ〜簡単に作ってみます。それも、あの Nx を使って。
Nx とは
Nx は Elixir のテンソル計算用ライブラリです。テンソルとは自然科学で出てくる値の表現で、スカラー・ベクトル・行列、にさらに続いて出てくる数学の構造です。ですので Nx は自然科学全般に広く使えるでしょう。特に Nx は機械学習目的に作成されたようで、これで Elixir の応用範囲も大きく広がると期待されています。でも、今回はテンソル的な使い方は一切しません。
ドキュメント
現状で Nx は Hex に上がっておらず、よくあるライブラリの中身を見る手段が使えません。
インストール前に個別の関数の詳細を見ようとすると github 上の Nx のコード を見るしかないようです。インストールすると iex のヘルプでドキュメントが見えます。
なお、Nx の入門については NX 公式ページの Resouces が役に立ちそうです。
ヒートマップ
ヒートマップとは、面上の図形に濃淡・色で値を表現したグラフです。比較的馴染みがあるのは携帯電話のサービスエリアの表示でしょうか。以下は NTT docomo のページで調べた私の職場周辺のサービスヒートマップです。かろうじて高知工科大学(地図の右上の赤ピン)が範囲に入っています。
これ、ヒートマップでお馴染みの例を出しただけなので、今回の記事でこんなのが作れるのかと勘違いしないようにしてください。要は2次元の空間にz軸方向の値を色や濃淡で表現したグラフということを言いたいのです。
インストール
インストール手順はざっくり以下です。
-
mix project_name
して Elixir プロジェクトを作成する - mix.exs に Nx への依存関係を追加する
-
mix deps.get
する
以下に依存関係の記述の例を示します1。
def deps do
[
{:nx, "~> 0.1.0-dev", github: "elixir-nx/nx", branch: "main", sparse: "nx"}
]
end
より詳しくは @mokichi さんの記事 やそこから参照している他の記事を御覧ください。
Nx によるヒートマップ
一通り Nx.to_heatmap
の機能を見てみましょう。
テンソル型の値を作る
Nx にはデータ型として Nx.Tensor
なる型があります。Nx の関数は基本的にこの型のデータに対して作用します。この型の値はリストから Nx.tensor
関数で生成することができます。
iex(43)> Nx.tensor([[0,1,2],[3,4,5]])
#Nx.Tensor<
s64[2][3]
[
[0, 1, 2],
[3, 4, 5]
]
>
簡単なヒートマップを作る
ヒートマップを作る関数は Nx.to_heatmap
です。ヒートマップを作るのはすべてこの Nx.Tensor
型のデータに対してです。iex で h Nx.to_heatmap
とやると使い方がわかります。
返すのは Nx.Heatmap
なる、引き数で与えたテンソルの値自体とそれからできるヒートマップをくっつけた型の値になります。
では先程のテンソルの値をヒートマップで見てみましょう。以下のようにすることで [[0,1,2],[3,4,5]]
の値全体をヒートマップで見ることができます。Nx.tensor([[0,1,2],[3,4,5]]) |> Nx.to_heatmap
とやってみてください。
リスト [[0,1,2],[3,4,5]]
の内容が、白から黒までのグレースケールで表現されています。
ヒートマップの濃さの決め方
ヒートマップは白から黒までの灰色の濃さで表現されています。
上の例では要素中の最小値である 0 が黒になってます。同様に要素中の最大値である 5 が白になっています。その途中の 1〜4 は灰色の濃さで表現されています。
値に対する灰色の濃さは与えられたテンソルの要素中の最大・最小の値で自動で調節されます。例えば 0 と 1 からなるヒートマップは市松模様になります。以下では 0 が黒、1 が白で表現されています。
セルの絶対的な濃さは値の絶対的な大きさとは無関係なことに注意してください。例えば Nx.tensor([[-2,10,-2],[10,-2,10]]) |> Nx.to_heatmap
とやっても、先程のと同じヒートマップが出ます。
大きなヒートマップ
これまでは 3x2 の大きさのテンソルでした。もっと大きいのを作ってみましょう。横に長めのリストを作ってみます。
iex(6)> 0..19 |> Enum.to_list
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19]
これのヒートマップは 0..19 |> Enum.to_list |> Nx.tensor |> Nx.to_heatmap
で作れます。
パッと見、20段階はあるので、これ以上の細かいグレースケールは可能そうです。
ターミナルエミュレータの大きさ
となると一気に100でどうだとか思ってしまいます。0..99 |> Enum.to_list |> Nx.tensor |> Nx.to_heatmap
の結果がこれ。
あれえ、なんだか濃さが変ですね。
これ、MacOSのターミナルエミュレータの ターミナル
アプリを横幅を80文字に設定して使ってます。もっと横幅が大きくて1行を改行で折り返さないで済むような横に伸ばしたターミナルエミュレータでやってみます。するときちんとグラデーションが見えます。
MacOS のターミナルアプリが悪いのか、Nx の to_heatmap
が悪いのか、はたまた Elixir の IO.ANSI
ライブラリが悪いのか、分かりません。とりあえず改行がないような環境でしのいでください。
んで、もとに戻って、よく見てみると100段階の値に対してヒートマップのグレースケールは100段階はないですね。
ヒートマップのオプション
この to_heatmap/2
関数の第2引数はオプションです。このオプションには2種類あります。
ANSIシーケンス対応でない端末を使う
Nx.to_heatmap
には :ansi_enabled
というオプションがあります。
これは、もしあなたが ASR-33 テレタイプライタとか IBM タイプライタ とかのANSIシーケンス非対応な端末で Nx をお使いの場合に使うオプションです。もしあなたが「そんな機械式の端末を今どき使うかね。私が使ってるのは ANSI シーケンス原器とも言える DEC VT100 だよ」と思っても用心してください。私が思うにおそらく IO.ANSI
を開発した連中は VT100 で検証してないはずです。
関数を呼び出す際に :ansi_enabled
を設定しない場合は IO.ANSI.enabled?
の結果を用います。これにより現在の端末が ANSI シーケンス対応かどうかを自動的に判別してます。
もし明示的にANSIシーケンスを使わないようにする場合はこの値を false
にして関数を呼び出してください。以下は 0..9 |> Enum.to_list |> Nx.tensor |> Nx.to_heatmap(ansi_enabled: false)
の結果です。
このように色の濃さではなく数値が入ります。これはたまたま 0〜9 の値しかないテンソル値のヒートマップとして 0〜9 を返していますが、テンソルの中身によらず常に 0〜9 の範囲に収めて返されます。以下は 0..19
と 0..99
の場合です。
0..99
の場合は、返ってくる 0〜9 の数が10個ずつになっていないことに注意してください。0 が6個連続して、その後 1〜8 がそれぞれ11個連続して、最後にまた 9 が6個連続しています。
基礎になる文字を変える
もう一つのオプションは :ansi_whitespace
です。一つ々々のセルはそれぞれ1文字になっており、デフォルトでは Unicode の "\u3000"
を使います。フォントセットによりますが、それぞれのセルが真四角の文字で印刷されることが期待されます。これを変更することも可能です。別の文字にしたいという要求がどれぐらいあるのか怪しいですが、例えば ○
すなわち "\u3007"
に変える場合は 0..9 |> Enum.to_list |> Nx.tensor |> Nx.to_heatmap(ansi_whitespace: "\u3007")
とします。
要りますかね、この機能…
Nx を使ってみる
ここまで Nx.to_heatmap
の基本的な扱いを書いてきました。ここからはもう少し大きなテンソルでこの関数を使ってみましょう。
まず、決め打ちで 0..9
とか書くのをやめたいのと、いろんな関数の結果を出したいので、そこまでをひとまとめにした関数 mkheatmap/3
を作ります。引数は以下。
- x: 横のセル数
- y: 縦のセル数
- fun: 縦方向(0〜x-1)と横方向(0〜y-1)の整数値を取ってなにかのスカラ値を返す関数
iex(52)> mkheatmap = fn(x, y, fun) ->
...(52)> 0..y-1
...(52)> |> Enum.to_list
...(52)> |> Enum.map(fn j ->
...(52)> (0..x-1
...(52)> |> Enum.to_list
...(52)> |> Enum.map(fn i -> fun.(i, j) end))
...(52)> end)
...(52)> |> Nx.tensor
...(52)> |> Nx.to_heatmap
...(52)> end
#Function<42.65746770/3 in :erl_eval.expr/5>
長いですね。一発で書くとこうなります。
mkheatmap = fn(x, y, fun) -> 0..y-1 |> Enum.to_list |> Enum.map(fn j -> (0..x-1 |> Enum.to_list |> Enum.map(fn i -> fun.(i, j) end)) end) |> Nx.tensor |> Nx.to_heatmap end
市松模様
ではまず、市松模様を作ってみます。奇遇性で 0 と 1 を分ければ良いので、こんな関数 checker/2
を作りました。
checker = fn(m, n) -> rem(m + n, 2) end
これを使って 横20 × 縦10 の市松模様をつくるには mkheatmap.(20, 10, checker)
とします。
九九表
掛け算する関数 mul9x9 = fn(m, n) -> m * n end
を使って九九のヒートマップも作れます。これで mkheatmap.(10, 10, mul9x9)
した結果が以下。
ちなみに足し算の九九の場合は sum9_9 = fn(m, n) -> m + n end
を使って mkheatmap.(10, 10, sum9_9)
します。
すり鉢
すり鉢状のポテンシャルを入れてみましょう。縦横を 11x11 と決め打ちして mortar/2
という関数を作り
mortar = fn(m, n) -> :math.sqrt((m - 5) * (m - 5) + (n - 5) * (n - 5)) end
mkheatmap.(11, 11, mortar)
としたのが以下です。
ちなみに、このヒートマップ、じっと見てるとなんだか濃淡が変化して見えませんかね。
リサージュ
三角関数も使ってみましょう。x軸方向(0〜31)いっぱいに1周期を取る sin 関数と、y軸方向(0〜31)いっぱいに1周期を取る cos 関数を使って、それらの積を返す関数 lissajous/2
を作り
lissajous = fn(m, n) -> :math.sin(m / 30.0 * 2.0 * :math.pi) * :math.cos(n / 30.0 * 2.0 * :math.pi) end
mkheatmap.(31, 31, lissajous)
としたのが以下です。
これ、複素数が使えたりするともっと面白くなりそうです。
ライフゲーム
計算機理論にセルオートマトンという一分野があります。その例として最も有名なのが Conway's Game of Life で、日本語では通称「ライフゲーム」と呼ばれています。随分前の Elixir 入門初期にライフゲームのプログラムを作成したことがあります。
このときの出力はコンソールに 0
1
をそのまま出力するものだったので、これをヒートマップで出してみることにします。
defmodule Nxlife do
@moduledoc """
This module plays Conway's Game of Life in single process.
"""
@doc"""
The function nth_gen generates new generations
from the initial field `world` for `n` times
where nth_gen(n, i, world).
Please feed 0 to `i` for displaying the generation cycle.
For exapmle,
Nxlife.nth_gen(10, 0,
{{0, 0, 0, 0, 0},
{0, 0, 1, 0, 0},
{0, 0, 0, 1, 0},
{0, 1, 1, 1, 0},
{0, 0, 0, 0, 0}})
shows a flying glider with 10 generations.
"""
def nth_gen(n, i, world, interval \\ 200) do
pp(i, world)
if n == i do
:ok
else
Process.sleep(interval)
nth_gen(n, i+1, next_gen(world), interval)
end
end
def pp(gen, world) do
IO.puts("#{gen}th")
for j <- 0..tuple_size(world)-1 do
elem(world, j) |> Tuple.to_list
end
|> Nx.tensor |> Nx.to_heatmap |> IO.inspect
end
def next_gen(current) do
(for j <- 0..tuple_size(current)-1 do
(for i <- 0..tuple_size(elem(current, 0))-1 do
get_neighbor(i, j, current) |> dead_or_alive()
end) |> List.to_tuple
end) |> List.to_tuple
end
def get_neighbor(x, y, world) do
rx = tuple_size(elem(world, 0))
ry = tuple_size(world)
[elem(elem(world, rem(y+ry-1, ry)), rem(x+rx-1, rx)),
elem(elem(world, rem(y+ry-1, ry)), x),
elem(elem(world, rem(y+ry-1, ry)), rem(x+ 1, rx)),
elem(elem(world, y), rem(x+rx-1, rx)),
elem(elem(world, y), x),
elem(elem(world, y), rem(x+ 1, rx)),
elem(elem(world, rem(y+ 1, ry)), rem(x+rx-1, rx)),
elem(elem(world, rem(y+ 1, ry)), x),
elem(elem(world, rem(y+ 1, ry)), rem(x+ 1, rx))]
end
def dead_or_alive(neighbor) do
me_now = Enum.at(neighbor, 4)
case (Enum.sum(neighbor) - me_now) do
2 -> me_now
3 -> 1
_ -> 0
end
end
def test5() do
Nxlife.nth_gen(20, 0,
{{0, 0, 0, 0, 0},
{0, 0, 1, 0, 0},
{0, 0, 0, 1, 0},
{0, 1, 1, 1, 0},
{0, 0, 0, 0, 0}})
end
def test10() do
Nxlife.nth_gen(100, 0,
{
{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
{0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
{0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
{0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}
},
100)
end
end
これの Nxlife.test10
とかをやってみた結果が以下です。
ヒートマップを使えない場合
ここまでやってて今更なのですが、単に行列のヒートマップを表示するというのに何も考えずに使うとエラー起こして止まる場合があることに気づきました。
極端な値の要素を持っている場合
テンソル内の値に特別な値があるとうまくいきません。以下は $1.0^{100}$ と $1.0^{-100}$ とを要素に持つテンソルのヒートマップを出そうとした場合です。
iex(2)> [1.0e100, 1.0e-100] |> Nx.tensor |> Nx.to_heatmap
%Inspect.Error{
message: "got MatchError with message \"no match of right hand side value: <<0, 0, 128, 127>>\" while inspecting %{__struct__: Nx.Heatmap, opts: [], tensor: #Nx.Tensor<\n f32[2]\n [Inf, 0.0]\n >}"
}
これはヒートマップを出す前の、テンソルに変換した段階ですでに単精度(32bit)浮動小数点数の表現範囲を超えてしまってます。Elixir/Erlang の浮動小数点の扱いは64bitの倍精度浮動小数点数しかありませんが、Nx には浮動小数点数に4種類あってデフォールトが32bitです。ですので
iex(17)> [1.0e100, 1.0e-100] |> Nx.tensor
#Nx.Tensor<
f32[2]
[Inf, 0.0]
>
と、大きい方の値は Inf
に、小さい方の値は 0.0
に丸められてます。この Inf
という NaN
に対応出来ないのです。これについては Nx の問題というより Erlang/OTP の NaN の扱いの問題なので、それが解決するのを待っているような状況 のようです。
ちなみにこの場合は [1.0e100, 1.0e-100] |> Nx.tensor(type: {:f, 64}) |> Nx.to_heatmap
と倍精度(64bit)浮動小数点数を明示的に使うことで、エラーからは逃れられます。
テンソル内のすべての要素が同一の場合
追記:これは 2021.12.30 時点の話です。これはすでに修正されています2。
テンソル値内の 全ての要素が同じ値の場合にはエラーしてしまいます。
試しに以下をやってみます。
[0] |> Nx.tensor |> Nx.to_heatmap
[1, 1] |> Nx.tensor |> Nx.to_heatmap
[[-1, -1, -1], [-1, -1, -1]] |> Nx.tensor |> Nx.to_heatmap
- 与えるテンソルに余分な要素を追加して、全部の要素が同じ値にならないようにする
- 与えるテンソル内の要素を一部変更して、全部の要素が同じ値にならないようにする
- 全部の要素の値が同じになる場合を検出して、その場合は値を渡さない
なおこれ、ソースコード heatmap.ex3 を見てみるとバグらしき部分がありましたので年末にデバッグとプルリクに勤しみたいと思います2。
まとめ
Elixir のテンソル計算ライブラリ Nx にある to_heatmap 関数を使ってお手軽にヒートマップを作ってみました。プログラム書いてて「データをサッと可視化したい、でも自分で可視化部分を書くのは面倒」というときに思い出してください。
さて、明日の fukuoka.ex Elixir/Phoenix Advent Calendar 2021 の記事は @the_haigo さんの LiveViewとNxで画像処理 です。お楽しみに!
参考文献
- Nx の readme.md
- ヒートマップ wikipedia
- docomo の 5Gエリアヒートマップ
- Elixirの革新的ライブラリ「Nx」をMacでも動かしてみた by @mokichi さん
- Elixir IO.ANSI
- Unicode一覧 3000-3FFF, Wikipedia
- Conway's Game of Life, Wikipedia
- ライフゲーム, Wikipedia
- はじめてな Elixir(25) ライフゲームを作ってみる
-
Nx のバージョンはこの記事を書いている時点のNx Installationです。常に最新のものをチェックしてください。 ↩
-
この記事を書いてて気がついて プルリク(#596) を出したところ、翌日の大晦日にマージされました。些細なことですが貢献できてハッピーです。 ↩ ↩2
-
見ているのは https://github.com/elixir-nx/nx/blob/main/nx/lib/nx/heatmap.ex の 2021.12.29 のものです。 ↩