[tensorflow.jsの使い方] 生のJavascript配列からTensor型に変換するときに覚えておきたいテクニック集


2020/12/31

今年も色々とありましたが、早いもので2020年も今日で終わりです。

本年の締めくくりにtensorflow.jsで配列からテンソル型を扱う際に個人に役立った基礎的なテクニックをいくつかまとめておきたいと思います。

tensorflow.jsでの開発では配列をテンソル型に変換、テンソルを別のテンソルに変換...などなど独特なテクニックが必要になってきます。

今回は著者が独断で選ぶ、tensorflow.jsを使うときに、「これはとても役立ったぞ」というオペレーターとその使用例を厳選して以降で紹介します。


配列とテンソルの違い

tensorflowで機械学習のコーディングしていくと、しばしばテンソルだけでは出来ない操作が混じっていて、一旦配列に戻して、配列のメソッドを使ってから再度テンソルに戻すようなことを考える必要があります。

こういうような場面に遭遇するたびに、「配列をテンソル型にする必要があるのか?」なんて内心では思っていますが、そもそもテンソルは代数学の重要で奥深い概念です。

参考サイト: テンソルとは何か、なぜテンソルという概念が必要となるのか

ので、著者のようにテンソルをボンヤリと配列の仲間みたいに使っていると、近年大量に投下されまくっている機械学習分野の最新の論文を正しく理解できない=更に上のステージにいけないように思います。

改めて、テンソルの定義を先程のサイトから拝借させていただくと...

VVWWの2つの線型空間を考える。このとき線型空間TTと双線型写像κ:V×WT\kappa: V \times W \rightarrow Tの組(T,κ)(T, \kappa)において、次の性質を満たすものが同型を除き唯一つ存在する。

[性質] 任意の線型空間
UUと任意の双線型写像ΦU:V×WU\Phi_U : V \times W \rightarrow Uに対してΦU=fUκ\Phi_U = f_U \circ \kappaを満たす線型写像fu:TUf_u: T \rightarrow Uが唯一つ存在する。

このとき組
(T,κ)(T, \kappa)VVWWのテンソル積、TTの元をテンソルと呼び、TTVWV \otimes Wκ(v,w)\kappa (v, w)vwv \otimes wと書く。また上記の性質をテンソル積(VW,κ)(V \otimes W, κ)の普遍性と呼ぶ。

...以上がテンソルの定義です。

改めて考えると、テンソルとはとても代数学から与えられる抽象的な意味が背景にありますが、対して配列はあくまでもメモリ空間に配置されたデータの参照という意味以外はないのです。

ではなぜテンソルが多様な分野で使われるのかというと、物理学のアプローチの一つに、既知の複数の物理量から、それとは別の新しい物理量を考えるケースがあります。その新しい物理量にも線形性が保証されるならば、複数の線型空間の元に対してテンソル積を新しい物理量(テンソル)として取り扱えることになります。

このテンソル(解析)を使う側にとって何が一番嬉しいのかというと、既知の物理量のセットから得られた新しい物理量が唯一に定まるため、その時の任意性を排除して議論することができるようになります。このテンソル積の仕組みがあることで、新しい物理量として安心して取り扱うことが保証されております。

そう考えるとtensorflowというのは、既知の観測量を幾重にも重なった線形写像に通して新しい意味のある物理量を得ることが目的のアプリケーションですので、確かに
テンソルがフローしているという意味が込められているのも納得です。

理屈はともかく、配列をテンソル型にしたら、できるだけ配列操作は使わず、テンソル型のクラスメソッドだけを使うように心がけるようになりたいものです。


配列 > テンソル型にするときの便利なテクニック集

難しい前置きになりましたがご容赦いただき、本題の配列-テンソル型の変換操作などの個人に使えるテクニックをまとめていきます。

テンソルとスカラーの四則演算

後半になるに従って頭が混乱するといけないので基礎からいきます。

            
            import * as tf from '@tensorflow/tfjs';

//👇1次元配列から1次元テンソル, Shape(形状): [3] に変換
const x = tf.tensor1d([1,2,3]);

//👇実数(Number型)からスカラー型に変換
const a = tf.scalar(4);

//👇テンソルxにスカラーaを足す
x.add(a).print(); // -> [5, 6, 7]

//👇テンソルxにスカラーaを引く
x.sub(a).print(); // -> [-3, -2, -1]

//👇テンソルxにスカラーaを掛ける
x.mul(a).print(); // -> [4, 8, 12]

//👇テンソルxにスカラーaを割る
x.div(a).print(); // -> [0.25, 0.5, 0.75]
        
単純な四則演算で、計算当然の結果のようにも見えますが、テンソルの線形性として意識して見ると、奥深い操作です。

またtensorflowのテンソルを上手く扱う上で、テンソルの形状(シェイプ,Shape)の表記、テンソル軸(次元数, axis)は常に頭に意識しておく必要があります。

上の例では、
[1,2,3]という要素数3つの一次元配列ですので、形状は[3]、軸数は1です。

余談で各演算オペレーターの引数には、スカラー型だけでなく、(javascriptの)配列型やNumber型も直接入れることができます。

            
            import * as tf from '@tensorflow/tfjs';

const b = tf.scalar(1/4);
x.mul(b).print(); // -> [0.25, 0.5, 0.75]
x.mul([1/4]).print(); // -> [0.25, 0.5, 0.75]
x.mul(1/4).print(); // -> [0.25, 0.5, 0.75]
        
このソースコードではプログラムが内部で同じことをやっているのですが、テンソル操作を意識するならきちんとtf.scalarでスカラー量であることを明示に書くべきだと感じます。

stackでテンソル成分の合成

tf.stackはテンソル同士の成分を合成させるのに欠かせないメソッドです。

まずは以下のように2次元テンソルを作成してみます。

            
            import * as tf from '@tensorflow/tfjs';

const xarr = [0, 1, 2, 3, 4, 5];
const yarr = [-1, 3, 2, 0, -2, 1];
const x = tf.tensor2d(xarr, [6, 1]); // Shape: [6,1]
const y = tf.tensor2d(yarr, [6, 1]); // Shape: [6,1]

x.print();
// 👇
// [[0],
//  [1],
//  [2],
//  [3],
//  [4],
//  [5]]

y.print();
// 👇
// [[-1],
//  [3 ],
//  [2 ],
//  [0 ],
//  [-2],
//  [1 ]]
        
これら形状[6,1]の2次元テンソルxyを順に合成して、形状[6,2]の2次元テンソルを得たい場合、stackを知らなければ、以下のように配列メソッドを使って苦肉の合成になるはずです。

            
            import * as tf from '@tensorflow/tfjs';

const xarr = [0, 1, 2, 3, 4, 5];
const yarr = [-1, 3, 2, 0, -2, 1];
const xyarr = xarr.map((e, i) => [e, yarr[i]]);

const xy = tf.tensor2d(xyarr, [6, 2]); // Shape: [6,2]
xy.print();
// 👇
// [[0, -1],
//  [1, 3 ],
//  [2, 2 ],
//  [3, 0 ],
//  [4, -2],
//  [5, 1 ]]
        
配列のmapメソッドを使って無理やり配列の要素を切り貼りしているやり方は中々慣れないと面倒です。こんなことをやらなくても、stackで以下のようにスマートに合成できます。

            
            import * as tf from '@tensorflow/tfjs';

const xarr = [0, 1, 2, 3, 4, 5];
const yarr = [-1, 3, 2, 0, -2, 1];
tf.stack([xarr, yarr], 1).print();
// 👇
// [[0, -1],
//  [1, 3 ],
//  [2, 2 ],
//  [3, 0 ],
//  [4, -2],
//  [5, 1 ]]
        
すこしだけ解説すると、stackの第一引数には、同じ形状・dtypeのテンソル型のオブジェクトを合成したい順番に配列で指定します。

第二引数は合成したい軸を指定しますが、合成前の形状([6,1])は軸数2ですので、
axis=0で最初の次元に、axis=1で2次元目で要素が合成できます。

今回は
[6,1] --> [6,2]の形状変換ですので、axis=1に指定するのが正しい使い方です。

unstackでテンソルの形状を一つひん剥く

形状を強制的に変えようとすると、tf.reshapeがありますが、結局のところ形状の総積数は変えることができません。

次元数を一つ落として、中身のテンソルの元を配列として得たい場合に使うのが
tf.unstackです。

一例として、形状[4,1,2]の3次元テンソルをunstackで各軸を指定したときの細かな違いを見ていきます。

            
            import * as tf from '@tensorflow/tfjs';

const xarr = [[[5,3]],[[8,1]],[[2,7]],[[3,0]]];
const x= tf.tensor3d(xarr, [4, 1, 2]); // Shape: [4,1,2]
x.print();
// 👇
// [ [[5, 3],],
//   [[8, 1],],
//   [[2, 7],],
//   [[3, 0],] ]

//👇最初の次元をunstack:
// [4,1,2] --> 4 x [1,2]
const xUnstackAlongAxis1 = x.unstack(0);
console.log(xUnstackAlongAxis1.length); // 4(=ひん剥いた次元の要素数と一致)
for (const tensor of xUnstackAlongAxis1) {
    tensor.print();
}
// 👇
// [[5, 3],]
// [[8, 1],]
// [[2, 7],]
// [[3, 0],]

//👇2番目の次元をunstack:
// [4,1,2] --> 1 x [4,2]
const xUnstackAlongAxis2 = x.unstack(1);
console.log(xUnstackAlongAxis2.length); // 1(=ひん剥いた次元の要素数と一致)
for (const tensor of xUnstackAlongAxis2) {
    tensor.print();
}
// 👇
// [[5, 3],
//  [8, 1],
//  [2, 7],
//  [3, 0]]

//👇3番目の次元をunstack:
// [4,1,2] --> 2 x [4,1]
const xUnstackAlongAxis3 = x.unstack(2);
console.log(xUnstackAlongAxis3.length); // 2(=ひん剥いた次元の要素数と一致)
for (const tensor of xUnstackAlongAxis3) {
    tensor.print();
}
// 👇
// [[5],
//  [8],
//  [2],
//  [3]]
// [[3],
//  [1],
//  [7],
//  [0]]
        
ポイントとしては、元のテンソル形状と比較して、unstackした後のテンソル配列の数が指定して潰した次元の要素数分あるということです。

テンソルの次元を一つづつ落としてくれるので、個人にはreshapeやsqueezeより感覚が掴みやすいと思います。

なお、ややこしいですが、unstackの逆の操作はstackではなく
expandDimsが正しそうです。

一次元テンソルの要素を左シフト

次はテンソルの要素を一つづつずらす操作の時に使えるテクニックです。

例として、一次元テンソルの
[1,2,3,4,5]の中身を[2,3,4,5,6]にする操作を考えてみます。

            
            import * as tf from '@tensorflow/tfjs';

const xarr = [1,2,3,4,5];
const x = tf.tensor1d(xarr);
x.print();
//👇
//[1, 2, 3, 4, 5]

//新たに付け加えるテンソル
const a = tf.tensor1d([6]);
a.print();
//👇
//[6]

//先頭の要素をスライスして、末尾にテンソルを加える
x.slice([1], [-1]).concat([a]).print();
// x.slice([1]).concat([a]).print(); // -1は省略可
//👇
// [2, 3, 4, 5, 6]
        
ここではテンソルの中身を切り取るsliceと複数の次元数の同じテンソルを結合させるconcatを組み合わせることで簡単に左シフトできます。

sliceの使い方には注意が必要で、第一引数は切り取り開始の(テンソルの)位置を指定します。

ここで言うと
[1]は最初の次元の2番目要素より前は切り捨てられます([2,3,4,5]が出力される)。

ちなみに二番目の引数は切り取り位置から取り出す残り要素のサイズです。

-1を指定すると切り取り位置から残り全てを取り出すことができます。

[-1]で最初の次元で切り取り位置から残り全ての要素を切り出すことを意味しています。

次いでconcatは同次元のテンソルを後ろから結合しているので、
[2, 3, 4, 5, 6]が得られます。

二次元テンソルの要素を左シフト

先程と同様の操作を2次元に拡張します。

基本的には先程のテクニックと全く同じですが、2次元なので左シフトで指定できる軸が2つとなり考え方もやや複雑です。

sliceの用法がポイントです。

最初のaxisへのシフトは簡単です。

            
            import * as tf from '@tensorflow/tfjs';

// [[1,2],[3,4],[5,6]] から [[3,4],[5, 6],[7, 8]]へ左シフト
const x = tf.tensor2d([[1, 2], [3, 4], [5, 6]]);
const a = tf.tensor2d([[7, 8]]);

x.slice([1], [-1]).concat([a]).print();
//👇
// [[3, 4], [5, 6], [7, 8]]
        
結果は先程と同じです。

でも、2番目のaxisを左シフトするにはconcatでは難しいので、上の節で紹介したstackを利用します。

            
            import * as tf from '@tensorflow/tfjs';

// [[1,2],[3,4],[5,6]] から [[2,3],[4, 5],[6, 7]]へ左シフト
const x = tf.tensor2d([[1, 2], [3, 4], [5, 6]]);
const b = tf.tensor2d([[3],[5],[7]]);

//ワンライナーで左シフトする
x.slice([0, 1], [-1,-1]).stack([b], 1).squeeze([2]).print();
//👇
// [[2, 3], [4, 5], [6, 7]]

///上のワンライナー操作で個別に分解してみる
//①2番目の軸の最初の要素から切り出し
x.slice([0,1],[-1,-1]).print();
//👇
// [[2],
//  [4],
//  [6]]

//②テンソルbと2番目の軸で結合
//形状[3,1]と[3,1]の結合は[3,2,1]の拡張として解釈
x.slice([0,1],[-1,-1]).stack([b], 1).print();
//👇
// [[[2],[3]],
//  [[4],[5]],
//  [[6],[7]]]

//③余分に出た3番目の軸を圧縮し次元を一つ落とす
//[3,2,1] --> [3,2]
x.slice([0,1],[-1,-1]).stack([b], 1).squeeze([2]).print();
//👇
// [[2, 3], [4, 5], [6, 7]]
        
ここでもsliceの使い方がポイントになります。使い方がわかると、3次元配列のシフトも作成できると思いますが、そこはご自身の宿題とさせていただきます。

なお、冗長な1次元を潰すのに
squeezeを使いましたが、unstackを使うこともできます。

            
            x.slice([0,1],[-1,-1]).stack([b],1).unstack(2)[0].print();
        
unstackはテンソルの配列を返すので、返ってくる配列の最初のオブジェクトを受け取る必要があります。

reshapeで汎用性の高いテンソル変換

reshapeはテンソル形状を自由自在に変形することできますが、操作の自由度が高すぎて、特に低次から高次へのテンソル変換が想像し難いので、人間の頭では使いにくいと思います。

そのためreshapeを多用すると可読性が著しくおちてバグの温床になりやすいので、実際には
tensor1dtensor6dをような用途の分かりやすいメソッドつかうに越したはありません。

ただどうしようもなくなった際の最終奥義として威力を発揮するかもしれません。

            
            import * as tf from '@tensorflow/tfjs';

const x = tf.tensor1d([1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16]);
x.print();
//👇 Shape: [16]
// [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]

x.reshape([1, 8, 2]).print();
//👇 [16] --> [1,8,2]
// [[[1 , 2 ],
//   [3 , 4 ],
//   [5 , 6 ],
//   [7 , 8 ],
//   [9 , 10],
//   [11, 12],
//   [13, 14],
//   [15, 16]]]

x.reshape([1, 8, 2]).reshape([16, 1]).print();
//👇 [16] --> [1,8,2] --> [16,1]
// [[1 ],
//  [2 ],
//  [3 ],
//  [4 ],
//  [5 ],
//  [6 ],
//  [7 ],
//  [8 ],
//  [9 ],
//  [10],
//  [11],
//  [12],
//  [13],
//  [14],
//  [15],
//  [16]]

x.reshape([1, 8, 2]).reshape([16, 1]).reshape([2, 2, 2, 2]).print();
//👇 [16] --> [1,8,2] --> [16,1] --> [2,2,2,2]
// [[[[1 , 2 ],
//    [3 , 4 ]],
//   [[5 , 6 ],
//    [7 , 8 ]]],
//  [[[9 , 10],
//    [11, 12]],
//  [[13, 14],
//   [15, 16]]]]
        
テンソルの総要素数がテンソル形状の各次元の要素数の積であれば、任意のテンソル形状に何度でも変換することができるなんともハチャメチャなメソッドです。

テンソルを分割するsplit

splitは同次元のテンソルを任意の要素数で分割するメソッドになります。

unstackと同様に返り値はテンソル型の配列になります。

            
            import * as tf from '@tensorflow/tfjs';

const x = tf.tensor2d([1,2,3,4,5,6,7,8,9,10,11,12], [4,3]);
x.print();
// [[1 , 2 , 3 ],
//  [4 , 5 , 6 ],
//  [7 , 8 , 9 ],
//  [10, 11, 12]]

//👇最初の次元で2つのテンソルに分割
const [a, b] = x.split([3,1], 0);

a.print();
// [[1, 2, 3],
//  [4, 5, 6],
//  [7, 8, 9]]

b.print();
// [[10, 11, 12],]
        
使い方はsliceに似ていますが、splitは第二引数で指定したaxisの要素で、分割する要素数を配列で指定します。

上の例では、最初の軸(
axis=0)に沿って、全要素数4を[3,1]で分割している意味になります。

テンソル型からNumber型配列への逆変換

最後にテンソルからNumberの配列に戻すテクニックを紹介します。

特に学習したモデルから予測後の値を取り出すときに利用する場合が多いと思います。

dataSyncメソッドはテンソル型(もしくはスカラー型)から中身に格納されたデータ型の配列を返す関数です。

以下の例でdataSyncの操作を試してみます。

            
            import * as tf from '@tensorflow/tfjs';

const x: tf.Scalar = tf.scalar(3);
console.log(x.dataSync()[0]); // 3
console.log(x.dataSync()[0] - 2); // 1 (Number型になっている)

const y: tf.Tensor = tf.tensor1d([4]);
console.log(y.dataSync()[0] - 2); // 2

const arr = [];
const a: tf.Scalar = tf.scalar(1);
const b: tf.Scalar = tf.scalar(2);
array.push(a);
array.push(b);

const values = array.map(t => t.dataSync()[0]);
console.log(values); // [ 1, 2 ]
        
ちなみにdataSyncと類似のメソッドにarraySyncがありますが、dataSyncとの違いはテンソル時の形状をそのまま残すか残さないかの違いになります。

dataSyncの場合には問答無用で1次元配列されてしまいますが、arraySyncはテンソル型に対応したネスト配列を返します。余程の理由が無い限りは、dataSyncを使う方が楽に値を取り出せると思います。


まとめ

今回はjavascript配列とtensorflow.jsのテンソル型のやりとりする操作を扱うときに、個人的に便利に使えたテクニック集と題して取りまとめてみました。

まだまだニッチなユーティリティは用意されいますが、今後便利なテクニックを見つけ次第、この記事の内容も更新して行こうかと思います。