LoginSignup
13
4

More than 1 year has passed since last update.

ヒートマップをお手軽に 〜テンソルでも機械学習でもない Elixir Nx の使い方〜

Last updated at Posted at 2021-12-29

これは 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 のページで調べた私の職場周辺のサービスヒートマップです。かろうじて高知工科大学(地図の右上の赤ピン)が範囲に入っています。

20211229_docomo_KUT.png

これ、ヒートマップでお馴染みの例を出しただけなので、今回の記事でこんなのが作れるのかと勘違いしないようにしてください。要は2次元の空間にz軸方向の値を色や濃淡で表現したグラフということを言いたいのです。

インストール

インストール手順はざっくり以下です。

  • mix project_name して Elixir プロジェクトを作成する
  • mix.exs に Nx への依存関係を追加する
  • mix deps.get する

以下に依存関係の記述の例を示します1

mix.exs
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 とやると使い方がわかります。
help.png
返すのは Nx.Heatmap なる、引き数で与えたテンソルの値自体とそれからできるヒートマップをくっつけた型の値になります。

では先程のテンソルの値をヒートマップで見てみましょう。以下のようにすることで [[0,1,2],[3,4,5]] の値全体をヒートマップで見ることができます。Nx.tensor([[0,1,2],[3,4,5]]) |> Nx.to_heatmap とやってみてください。
heatmap1.png
リスト [[0,1,2],[3,4,5]] の内容が、白から黒までのグレースケールで表現されています。

ヒートマップの濃さの決め方

ヒートマップは白から黒までの灰色の濃さで表現されています。

上の例では要素中の最小値である 0 が黒になってます。同様に要素中の最大値である 5 が白になっています。その途中の 1〜4 は灰色の濃さで表現されています。

値に対する灰色の濃さは与えられたテンソルの要素中の最大・最小の値で自動で調節されます。例えば 0 と 1 からなるヒートマップは市松模様になります。以下では 0 が黒、1 が白で表現されています。
heatmap_check.png

セルの絶対的な濃さは値の絶対的な大きさとは無関係なことに注意してください。例えば Nx.tensor([[-2,10,-2],[10,-2,10]]) |> Nx.to_heatmap とやっても、先程のと同じヒートマップが出ます。
heatmap_check2.png

大きなヒートマップ

これまでは 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 で作れます。
heatmap_0_19.png
パッと見、20段階はあるので、これ以上の細かいグレースケールは可能そうです。

ターミナルエミュレータの大きさ

となると一気に100でどうだとか思ってしまいます。0..99 |> Enum.to_list |> Nx.tensor |> Nx.to_heatmap の結果がこれ。
heatmap_0_99.png
あれえ、なんだか濃さが変ですね。

これ、MacOSのターミナルエミュレータの ターミナル アプリを横幅を80文字に設定して使ってます。もっと横幅が大きくて1行を改行で折り返さないで済むような横に伸ばしたターミナルエミュレータでやってみます。するときちんとグラデーションが見えます。
heatmap_0_99_long.png
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 シーケンス対応かどうかを自動的に判別してます。
IO_ansi_enabled.png
もし明示的にANSIシーケンスを使わないようにする場合はこの値を false にして関数を呼び出してください。以下は 0..9 |> Enum.to_list |> Nx.tensor |> Nx.to_heatmap(ansi_enabled: false) の結果です。
ansi_enabled_false.png
このように色の濃さではなく数値が入ります。これはたまたま 0〜9 の値しかないテンソル値のヒートマップとして 0〜9 を返していますが、テンソルの中身によらず常に 0〜9 の範囲に収めて返されます。以下は 0..190..99 の場合です。
ansi_false_20.png
ansi_false_100.png
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") とします。
heatmap_u3007.png
要りますかね、この機能…

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) とします。

checker20x10.png

九九表

掛け算する関数 mul9x9 = fn(m, n) -> m * n end を使って九九のヒートマップも作れます。これで mkheatmap.(10, 10, mul9x9) した結果が以下。

mul9x9.png

ちなみに足し算の九九の場合は sum9_9 = fn(m, n) -> m + n end を使って mkheatmap.(10, 10, sum9_9) します。
sum9_9.png

すり鉢

すり鉢状のポテンシャルを入れてみましょう。縦横を 11x11 と決め打ちして mortar/2 という関数を作り

mortar = fn(m, n) -> :math.sqrt((m - 5) * (m - 5) + (n - 5) * (n - 5)) end

mkheatmap.(11, 11, mortar) としたのが以下です。

mortar.png

ちなみに、このヒートマップ、じっと見てるとなんだか濃淡が変化して見えませんかね。

リサージュ

三角関数も使ってみましょう。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) としたのが以下です。
lissajous.png

これ、複素数が使えたりするともっと面白くなりそうです。

ライフゲーム

計算機理論にセルオートマトンという一分野があります。その例として最も有名なのが 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-0.png
heatmap-1_1.png
heatmap-xxx-xxx.png
これを回避するのに安直に考えると以下の方法があります。

  • 与えるテンソルに余分な要素を追加して、全部の要素が同じ値にならないようにする
  • 与えるテンソル内の要素を一部変更して、全部の要素が同じ値にならないようにする
  • 全部の要素の値が同じになる場合を検出して、その場合は値を渡さない

なおこれ、ソースコード heatmap.ex3 を見てみるとバグらしき部分がありましたので年末にデバッグとプルリクに勤しみたいと思います2

まとめ

Elixir のテンソル計算ライブラリ Nx にある to_heatmap 関数を使ってお手軽にヒートマップを作ってみました。プログラム書いてて「データをサッと可視化したい、でも自分で可視化部分を書くのは面倒」というときに思い出してください。

さて、明日の fukuoka.ex Elixir/Phoenix Advent Calendar 2021 の記事は @the_haigo さんの LiveViewとNxで画像処理 です。お楽しみに!

参考文献


  1. Nx のバージョンはこの記事を書いている時点のNx Installationです。常に最新のものをチェックしてください。 

  2. この記事を書いてて気がついて プルリク(#596) を出したところ、翌日の大晦日にマージされました。些細なことですが貢献できてハッピーです。 

  3. 見ているのは https://github.com/elixir-nx/nx/blob/main/nx/lib/nx/heatmap.ex の 2021.12.29 のものです。 

13
4
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
13
4