Elixir Nx の基本的な概念や動作を、以下のドキュメントに沿って確認しました。Livebook 上で動作させています。
Introduction to Nx 公式ドキュメント
関連記事
Elixir Nx で学ぶ機械学習 - Axon無し (Livebook) - Qiita
Elixir Axon で学ぶ機械学習 (Livebook) - Qiita
Elixir Nx の基礎 (Livebook) - Qiita
Livebook 事始め (Elixir) - Qiita
1. Nx 概要
まず Livebook 上で必要なインストールを行っておきます。
Mix.install([
{:nx, "~> 0.2"}
])
Nx は Elixir が効率よく数値計算を行うためのライブラリです。それは 型を持った多次元データ である テンソル 上で計算を行います。Nx は基本的に次の 3 つの能力を持っています。
- Nx において テンソル は 型を持ったデータ であり、多次元であり、しかも次元には 名前 を与えることができる。
- 数値関数の定義 (defn) で カスタムコードを関数化できます。それは tensor-aware オペレーションであり、関数です。
- 自動微分 (Automatic differentiation) は、 autograd または autodiff として知られており、共通の数値計算のシナリオをサポートしています。機械学習 やシュミレーション, 曲線回帰、確率モデルなどです。
テンソルの定義は、型を持った多次元データである、ということです。単に、何重にも入れ子になった配列のイメージです。物理学で言う何らかの物理量を表すテンソルとは無関係です。
自動微分は機械学習やディープラーニングにおいて勾配計算を行う、最重要な概念です。
何故、微分 が必要になるかについては、興味があれば以下の過去記事を参照してください。
【機械学習】誤差逆伝播法のコンパクトな説明
Nx テンソルは以下の型を持ちます。
- unsigned integers - u8, u16, u32, u64
- signed integers - s8, s16, s32, s64
- floats - f32, f64
- brain floats - bf16
- complex - c64, c128
2. Nx のテンソル
Nx のテンソルは、何ら物理量とは対応していないといいましたが、テンソルという言葉や概念は物理学由来だと思います。物理学ではスカラーは零階のテンソル、ベクトルは一階のテンソル、二階のテンソルと呼ぶらしいですが、ここでは次元という言葉を使っています。
- 0次元テンソル - スカラー
- 1次元テンソル - ベクトル
- 2次元テンソル - 行列
- 3次元テンソル - 行列の配列
- ...
- n次元テンソル - (n-1)次元テンソルの配列
2-1. テンソルの作成
Elixir リストからテンソルを作成することができます。以下のヘルプに詳細な例がありますが、いくつか見ていきましょう。
import IEx.Helpers
h Nx.tensor
0次元テンソル
Nx.tensor(0)
#Nx.Tensor<
s64
0
>
1次元テンソル
Nx.tensor([1, 2, 3])
#Nx.Tensor<
s64[3]
[1, 2, 3]
>
Nx.tensor([1.2, 2.3, 3.4, 4.5])
#Nx.Tensor<
f32[4]
[1.2000000476837158, 2.299999952316284, 3.4000000953674316, 4.5]
>
型を明示的に指定できる。型指定で最大値を超えた場合はオーバフローとなる。
2進数(1,000,000 300) = 10進数(256)なので、 8ビットでオーバーフローを考慮すると、300 -256 = 44 になる。自動的に以下のような変換が行われる。
Nx.tensor([300, 301, 302], type: :s8)
#Nx.Tensor<
s8[3]
[44, 45, 46]
>
2次元テンソル
Nx.tensor([[1, 2, 3], [4, 5, 6]])
#Nx.Tensor<
s64[2][3]
[
[1, 2, 3],
[4, 5, 6]
]
>
これは以下のような行列に相当します。
\begin{pmatrix} 1 & 2 & 3\\ 4 & 5 & 6 \end{pmatrix}
3次元テンソル
Nx.tensor([[[1, 2], [3, 4], [5, 6]], [[-1, -2], [-3, -4], [-5, -6]]])
#Nx.Tensor<
s64[2][3][2]
[
[
[1, 2],
[3, 4],
[5, 6]
],
[
[-1, -2],
[-3, -4],
[-5, -6]
]
]
>
次元軸に名前を付ける
テンソル次元に名前を付けることができます。コードの可読性を高めてくれます。names は atom のリストですが、テンソルの多次元配列の入れ子の浅い軸の順に並びます。
t1 = Nx.tensor([1, 2, 3], names: [:x])
#Nx.Tensor<
s64[x: 3]
[1, 2, 3]
>
t2 = Nx.tensor([[1, 2, 3], [4, 5, 6]], names: [:x, :y])
#Nx.Tensor<
s64[x: 2][y: 3]
[
[1, 2, 3],
[4, 5, 6]
]
>
t3 = Nx.tensor([[[1, 2, 3], [4, 5, 6], [7, 8, 9]],
[[-1, -2, -3], [-4, -5, -6], [-7, -8, -9]]], names: [:batch, :height, :width])
#Nx.Tensor<
s64[batch: 2][height: 3][width: 3]
[
[
[1, 2, 3],
[4, 5, 6],
[7, 8, 9]
],
[
[-1, -2, -3],
[-4, -5, -6],
[-7, -8, -9]
]
]
>
名前でテンソルの要素にアクセスしてみます。あわせて Nx.shape/1 でテンソルの shape を取得し、Nx.reshape/2 で shape を変更します。
t1[x: 1]
#Nx.Tensor<
s64
2
>
t2[x: 1]
#Nx.Tensor<
s64[y: 3]
[4, 5, 6]
>
t2[y: 1]
#Nx.Tensor<
s64[x: 2]
[2, 5]
>
t2[x: 0, y: 1]
#Nx.Tensor<
s64
2
>
t3[height: 1]
#Nx.Tensor<
s64[batch: 2][width: 3]
[
[4, 5, 6],
[-4, -5, -6]
]
>
t3[width: 2]
#Nx.Tensor<
s64[batch: 2][height: 3]
[
[3, 6, 9],
[-3, -6, -9]
]
>
Nx.shape(t1)
{3}
Nx.shape(t2)
{2, 3}
Nx.shape(t3)
{2, 3, 3}
Nx.reshape(t3, {6, 3}, names: [:batches, :values])
#Nx.Tensor<
s64[batches: 6][values: 3]
[
[1, 2, 3],
[4, 5, 6],
[7, 8, 9],
[-1, -2, -3],
[-4, -5, -6],
[-7, -8, -9]
]
>
バイナリ変換
Nx.tensor([[1, 2], [3, 4]], type: :u8) |> Nx.to_binary()
<<1, 2, 3, 4>>
Nx.from_binary(<<0, 1, 2>>, :u8)
#Nx.Tensor<
u8[3]
[0, 1, 2]
>
2-2. テンソル関数
テンソル関数とは任意の shape のテンソルを引数にとれる関数です。
Nx.sum(t3[0])
#Nx.Tensor<
s64
45
>
Nx.sum(t2)
#Nx.Tensor<
s64
21
>
Nx.sum(t2, axes: [:x])
#Nx.Tensor<
s64[y: 3]
[5, 7, 9]
>
tensor1 = Nx.tensor([[1, 2], [3, 4]])
tensor2 = Nx.tensor([[5, 6], [7, 8]])
Nx.subtract(tensor2, tensor1)
#Nx.Tensor<
s64[2][2]
[
[4, 4],
[4, 4]
]
>
2-3. ブロードキャスト
テンソルでの計算ですが、例えば以下のような引き算は、
\begin{pmatrix} 1 & 2 & 3\\ 4 & 5 & 6 \end{pmatrix} - 1 = \begin{pmatrix} 0 & 1 & 2\\ 3 & 4 & 5 \end{pmatrix}
数学的な意味では以下の引き算に置き換えられると、便利です。
\begin{pmatrix} 1 & 2 & 3\\ 4 & 5 & 6 \end{pmatrix} - \begin{pmatrix} 1 & 1 & 1\\ 1 & 1 & 1 \end{pmatrix} = \begin{pmatrix} 0 & 1 & 2\\ 3 & 4 & 5 \end{pmatrix}
Nx.broadcast/2 を使えば、1 を {2, 2} テンソルに変換してくれます。
Nx.broadcast(1, {2, 2})
#Nx.Tensor<
s64[2][2]
[
[1, 1],
[1, 1]
]
>
Nx.broadcast(1, tensor) は 1 を tensor の shape にブロードキャストしてくれます。また例えば Nx.subtract(tensor, 1) も2つの引数のテンソルの shape を互換性のあるものへと自動的にブロードキャストしてくれます。
[[1], [2]] |> Nx.tensor() |> Nx.broadcast({1, 2, 4})
#Nx.Tensor<
s64[1][2][4]
[
[
[1, 1, 1, 1],
[2, 2, 2, 2]
]
]
>
以下のヘルプで多くの example が表示できます。
h Nx.broadcast
3. 数値関数の定義 (defn)
defn を使って好きなテンソル関数を作り出すことができます。引数として、スカラーやベクトル、行列などのテンソルを受けることができます。
引き算と足し算の関数を定義します。
defmodule TensorMath do
import Nx.Defn
defn hiku(a, b) do
a - b
end
defn tasu(a, b) do
a + b
end
end
行列テンソル同士の引き算です。
tensor1 = Nx.tensor([[1, 1], [1, 1]])
tensor2 = Nx.tensor([[5, 6], [7, 8]])
TensorMath.hiku(tensor2, tensor1)
#Nx.Tensor<
s64[2][2]
[
[4, 5],
[6, 7]
]
>
tensor1 をスカラー1に変えても同じ結果です。
TensorMath.hiku(tensor2, 1)
#Nx.Tensor<
s64[2][2]
[
[4, 5],
[6, 7]
]
>
足し算についても同じ結果が得られます。
TensorMath.tasu(tensor2, tensor1)
#Nx.Tensor<
s64[2][2]
[
[6, 7],
[8, 9]
]
>
TensorMath.tasu(tensor2, 1)
#Nx.Tensor<
s64[2][2]
[
[6, 7],
[8, 9]
]
>
4. 自動微分 (autograd)
h Nx.Defn.grad
Nx.Defn.grad(fun) は無名関数を引数として、新たな無名関数を作成し返してくれます。その無名関数は与えられた地点の勾配を求めてくれます。
sinの微分はcosですので、0 地点の勾配は 1 になります。
fun = Nx.Defn.grad(fn x -> Nx.sin(x) end)
fun.(Nx.tensor(0))
#Nx.Tensor<
f32
1.0
>
以下のような f(x) 関数の導関数は g(x) となります。
f(x) = 3x^{2} + 2x + 1\\
g(x) = 6x + 2
この事実をもとに Nx の自動微分を行ってみます。
地点 2 の勾配を求めてみます。
defmodule Funs do
import Nx.Defn
defn poly(x) do
3 * Nx.power(x, 2) + 2 * x + 1
end
defn poly_slope_at(x) do
grad(&poly/1).(x)
end
end
Funs.poly_slope_at(2)
#Nx.Tensor<
f32
14.0
>
g(2) = 14 の結果とあっています。
5. テンソルの軸 (Axis)
テンソルの軸は少しわかりにくい概念なので、もう少し述べておきたいと思います。
便利な関数なのですが、Nx.iota() に shape を与えると、0 始まりの連番の値を持つテンソルを作ってくれます。
Nx.iota({5})
#Nx.Tensor<
s64[5]
[0, 1, 2, 3, 4]
>
Nx.iota({3, 2, 3}, names: [:batch, :height, :width])
#Nx.Tensor<
s64[batch: 3][height: 2][width: 3]
[
[
[0, 1, 2],
[3, 4, 5]
],
[
[6, 7, 8],
[9, 10, 11]
],
[
[12, 13, 14],
[15, 16, 17]
]
]
>
テンソルの軸はテンソルの多次元配列の深さを示すものですが、axis_index(tensor, axis) はテンソルの軸を与えることによって、テンソルの index を返してくれるものです。
-1 は一番最後の軸に相当します。
Nx.axis_index(Nx.iota({100, 10, 20}), 0)
0
Nx.axis_index(Nx.iota({100, 10, 20}), -1)
2
Nx.axis_index(Nx.iota({100, 10, 20}, names: [:batch, :x, :y]), :x)
1
axis_size(tensor, axis) は与えられた軸のサイズを返します。
Nx.axis_size(Nx.iota({100, 10, 20}), 0)
100
Nx.axis_size(Nx.iota({100, 10, 20}, names: [:batch, :x, :y]), :y)
20
new_axis(tensor, axis, name \ nil) はサイズ1 の軸を追加します。
t = Nx.tensor([[1, 2, 3], [4, 5, 6]])
#Nx.Tensor<
s64[2][3]
[
[1, 2, 3],
[4, 5, 6]
]
>
Nx.new_axis(t, 0, :new)
#Nx.Tensor<
s64[new: 1][2][3]
[
[
[1, 2, 3],
[4, 5, 6]
]
]
>
Nx.new_axis(t, 1, :new)
#Nx.Tensor<
s64[2][new: 1][3]
[
[
[1, 2, 3]
],
[
[4, 5, 6]
]
]
>
Nx.new_axis(t, 2, :new)
#Nx.Tensor<
s64[2][3][new: 1]
[
[
[1],
[2],
[3]
],
[
[4],
[5],
[6]
]
]
>
Nx.new_axis(t, -1, :new)
#Nx.Tensor<
s64[2][3][new: 1]
[
[
[1],
[2],
[3]
],
[
[4],
[5],
[6]
]
]
>
(例)one-hot エンコーディング
one-hot エンコーディング とは、例えば 3 というスカラー値を、[0, 0, 0, 1, 0, 0, 0, 0, 0, 0] という 3番目だけ 1 で他は全て 0 のベクトルに変換することです。0 番始まりの 3 番目です。
わかり易いように段階を踏みます。
left =
1..4
|> Enum.to_list
|> Nx.tensor
|> Nx.new_axis(-1)
#Nx.Tensor<
s64[4][1]
[
[1],
[2],
[3],
[4]
]
>
right = Nx.tensor(Enum.to_list(0..9))
#Nx.Tensor<
s64[10]
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
>
Nx.equal/2 は2つの tensor を比べて、等しい箇所を1に、そうでない個所を0にします。
Nx.equal(left, right)
#Nx.Tensor<
u8[4][10]
[
[0, 1, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 1, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 1, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 1, 0, 0, 0, 0, 0]
]
>
(補足)内部では以下のようなブロードキャストが行われた後で、Nx.equal で要素ごとの比較が行われているものと推測されます。
left =
[
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[2, 2, 2, 2, 2, 2, 2, 2, 2, 2],
[3, 3, 3, 3, 3, 3, 3, 3, 3. 3],
[4, 4, 4, 4, 4, 4, 4, 4, 4, 4]
]
right =
[
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
]
今回は以上です。