torutkのブログ

ソフトウェア・エンジニアのブログ

Java 8 Stream APIでプリミティブ型の2次元配列を生成

はじめに

id:torutk:20170114 で、書籍「Deep Learning Javaプログラミング」のサンプルコードをfor文からStream APIに書き換えてみました。ただしこの日はforEachで処理を書くところで妥協しました。

Stream APIを使ってデータ処理をプログラミングすることは、プログラムの処理の基本となる入力・処理・出力(IPO:Input Process Outout)を、抽象度を高く、簡潔に記述することになります。

抽象度を高くとは、計算機寄りの表現から人間寄りの表現をすることにつながり、何をしているか(what)がソースコードを読んで理解しやすくなります*1

簡潔に記述とは、ここではネスト(制御の入れ子:forの中のif-elseの中のif文、など)や分岐(if文だけでなく、breakやcontinue)が少ないこと、つまり構造が複雑か単純かを意図しており、行数や文字数が少ないことを意図していません。

しかし、JavaのStream APIラムダ式Java SE 8で追加された割と新しい言語仕様で、数十年来の馴染みであるfor文とは比べ物になりません。普段から少しずつ使って馴染んでいくのが習得の早道と思います。

Stream APIの処理の流れ

Stream APIが扱うデータ処理の模式図は次のようになります。

入力データ → データのストリーム -> 中間処理 -> 終端処理 -> 出力データ

ところが、終端処理のforEachでデータの出力処理を行うと、次の模式図のように、一つの出力ではなく、あちこちのデータに書かれたことが出力となってしまいます。

入力データ → データのストリーム -> 終端処理(forEach)  ※ 出力データなし
                                       |  |  |
                                    +--+  |  +--+
                                    V     V     V
                              データX  データY  データZ

これでは抽象度が下がって、複雑さが大きくなってしまいます。Stream APIの目玉の一つパラレル(並列処理)も、forEachの処理をスレッドセーフに記述する必要があり、parallel()のおまじないを唱えるだけでは済みません。

ということで、「Stream APIの終端処理 forEachでデータの出力処理を記述しない」、を目標とすることにします。

for文でデータ処理

Deep Learning Javaプログラミング」で学習データを生成する処理があります。

学習データと正解データを所定の件数だけ正規分布の乱数を使って生成します。
学習データは、1件につき2個のdouble値(X1, X2)です。
正解データは、1件につき1個のint値です。

プログラム上では、学習データを総件数と1件あたりのデータの2次元配列 doubleで保持します。
正解データを総件数分 int[]で保持します。

    public static final int NUM_TRAIN = 2000; // 学習データの件数

    private double[][] trainData; // 学習データ
    private int[][] trainTeachers; // 正解データ
    private Random random = new Random(1234); // 乱数生成

乱数で学習データを生成して詰めます。総件数のうち前半を正解データのクラス1、後半を正解データのクラス2に分類し、クラス1とクラス2とでは乱数の平均値を変えて生成します。

	// クラス1のデータセット
	for (int i = 0; i < NUM_TRAIN / 2; i++) {
	    trainData[i][0] = random.nextGaussian() - 2.0;
	    trainData[i][1] = random.nextGaussian() + 2.0;
	    trainTeachers[i] = 1;
	}
	// クラス2のデータセット
	for (int i = NUM_TRAIN / 2; i < NUM_TRAIN; i++) {
	    trainData[i][0] = random.nextGaussian() + 2.0;
	    trainData[i][1] = random.nextGaussian() - 2.0;
	    trainTeachers[i] = -1;
	}

Stream APIでデータ処理(forEachでお茶を濁す

先のfor文で実現されている処理を、Stream APIを使って書き換えていきます。
といっても、いきなり綺麗な実装は考え付かなかったので、まずはforEachでお茶を濁します。
ここまでは、id:torutk:20170114 で記載した内容とほぼ同じです。

	// クラス1のデータセット
	IntStream.range(0, NUM_TRAIN / 2)
	    .forEach(i -> {
		trainData[i][0] = random.nextGaussian() - 2.0;
		trainData[i][1] = random.nextGaussian() + 2.0;
		trainTeachers[i] = 1;
	    });
	// クラス2のデータセット
	IntStream.range(NUM_TRAIN / 2, NUM_TRAIN)
	    .forEach(i -> {
	        trainData[i][0] = random.nextGaussian() + 2.0;
		trainData[i][1] = random.nextGaussian() - 2.0;
		trainTeachers[i] = -1;
	    });

forEachの中で、外部の学習データと正解データを書き換える処理を実行しています。
ほとんどfor文と一緒で、かつ複雑になってしまっています。ストリームの処理としては、ストリームの出力をなしにして、終端処理の中に本来中間処理でやるべきデータ作成と出力をを書いてしまっています。

Stream APIでデータ処理(力技でforEeachをなくした版)

では、本来のストリームの処理に近づけるべく、ストリームの出力を学習データにしていきます。
ここで、学習データは2次元配列です。Stream APIで配列を出力するサンプルを探すと大抵は1次元配列のものです。2次元配列の場合どうすればいいでしょうか?

Javaの2次元配列は、実際には配列の配列なので、double オブジェクトの1次元配列とみなすことができます。あとは、Stream を流れる学習データをdoubleオブジェクトとすればよいだけとなります。

まず、forEachで実現していた処理を、機械的に(力技)でStreamの中間処理に移します。
かなり長いし冗長な記述になっているので、これならfor文(forEach)の方がいいというレベルです。これは後ほどリファクタリグして解決していきます。

	// クラス1のデータセット
	double[][] trainData1 = IntStream.range(0, NUM_TRAIN / 2)
	    .mapToObj(i -> new double[] {
		    random.nextGaussian() - 2.0,
		    random.nextGaussian() + 2.0})
	    .toArray(double[][]::new);
	int[] trainTeachers1 = IntStream.range(0, NUM_TRAIN / 2)
	    .map(i -> 1)
	    .toArray();

学習データの生成と正解データの生成とでストリームを別々に使用します。

学習データのストリームには、中間処理のmapToObjを入れて、intの値からdouble を生成します。
このmapToObj の処理により、ストリームの型が IntStreamからStream>に変わります。
終端処理のtoArrayは、IntStreamではなくStream>に対する呼び出しとなります。そこで、toArrayの引数には、double
の配列であるdoubleを生成するコンストラクタ参照を渡します。

正解データのストリームには、中間処理のmapを入れて、intの値からintの値(1)を生成します。
ストリームの型はIntStreamのままなので、終端処理のtoArrayは、IntStreamに対する呼び出しとなり、コンストラクタ参照を渡さなくてもint[] 型の配列を生成します。

さて、ここまでは半分のデータしか生成していません。もう半分のデータを生成し、最後にデータを結合する必要があります。

	// クラス2のデータセット
	double[][] trainData2 = IntStream.range(NUM_TRAIN / 2, NUM_TRAIN)
	    .mapToObj(i -> new double[] {
		    random.nextGaussian() + 2.0,
		    random.nextGaussian() - 2.0})
	    .toArray(double[][]::new);
	int[] trainTeachers2 = IntStream.range(0, NUM_TRAIN / 2)
	    .map(i -> -1)
	    .toArray();

生成するデータの値の符号が異なる以外はほとんど一緒です。

クラス1の学習データ・正解データと、クラス2の学習データ・正解データを結合して1つとします。

	// クラス1とクラス2のデータを結合
	trainData = Stream.concat(
 	       Arrays.<double[]>stream(trainData1),
	         Arrays.<double[]>stream(trainData2))
	    .toArray(double[][]::new);

	trainTeachers = IntStream.concat(
                Arrays.stream(trainTeachers1), Arrays.stream(trainTeachers2))
            .toArray();

配列の結合なのでわざわざStream APIを使うことはないかと思っていましたが、Javaの標準APIには配列を結合するconcat的なものが見当たらず、大抵のサンプルはSystem.arrayCopyを駆使して実装しています。それならば(性能は差し置いて)Streamのconcatに任せた方がインデックス計算に煩わされずに済みます。

Stream APIでデータ処理(リファクタリング-1)

学習データで生成するデータが2種類(クラス1とクラス2)あるので、ストリームも2つ作って結果を結合する処理を書きました。ここでデータの違いを見ると、IntStream.rangeが生成する値に依存しています。ということは、中間処理mapToObjに渡ってくる値によって生成する式を変えることで、ストリームを1つにすることができそうです。

	trainData = IntStream.range(0, NUM_TRAIN)
	    .mapToObj(i -> {
		    if (i < NUM_TRAIN / 2) {
			return new double[] {
			    random.nextGaussian() - 2.0,
			    random.nextGaussian() + 2.0
			};
		    } else {
			return new double[] {
			    random.nextGaussian() + 2.0,
			    random.nextGaussian() - 2.0
			};
		    }
		})
	    .toArray(double[][]::new);

	trainTeachers = IntStream.range(0, NUM_TRAIN)
	    .map(i -> (i < NUM_TRAIN / 2) ? 1 : -1)
	    .toArray();

ストリームは1本にまとまったものの、mapToObj の引数部分が10行を超え、ネストもあって簡潔とはとても言い難いコードです。

Stream APIでデータ処理(リファクタリング-2)

リファクタリング-1において、mapToObjの中は、前段から渡された整数値により2つのデータを作り分けているので、この部分をメソッドに抽出します。

    private double[] createDatum(int klass) {
	if (klass == 1) {
	    return new double[] {
		random.nextGaussian() - 2.0,
		random.nextGaussian() + 2.0
	    };
	} else {
	    return new double[] {
		random.nextGaussian() + 2.0,
		random.nextGaussian() - 2.0
	    };
	}
    }

すると、ストリームのデータの生成は次のように記述できます。

	trainData = IntStream.range(0, NUM_TRAIN)
	    .map(i -> (i < NUM_TRAIN / 2) ? 1 : -1)
	    .mapToObj(this::createDatum)
	    .toArray(double[][]::new);

大変すっきり書くことができました。

まとめ

2次元配列のデータ出力をするストリームの記述ができました

おまけ

Webブラウザを執筆ツールとするのは厳しいなぁ、ということ。

ブログやWikiを書くときは、たいていWebブラウザ上で編集モードに入って書いています。
この日記を書いている途中で、朝食の支度を始めたところ、突然ノートPCのファンが騒音を上げました。何事?とキッチンから戻ってみると、Firefoxが応答なし状態で、Windows 10全体の画面の操作(マウス操作ほか)が異様に遅く、CPU使用率が高い状態となっていました。

超スローモーなマウス操作の中、Firefoxウィンドウにフォーカスを写し、応答なしで描画が崩れた状態から待つこと十数分、編集画面にフォーカスが移り、キー操作で全選択&コピー(Ctrl-A と Ctrl-C)が何とか成功したように見えます。右クリックでポップアップメニューがでるのに数十秒だったので、キー操作で実施しました。

そして、エディタ(Emacs)にペーストしたけれど、前のテキストがペーストされただけ、再度超スローモーなマウス操作でFirefoxの編集エリアにフォーカスを写し、再びCtrl-AとCtrl-Cを実施、しばらく間をおいてからエディタにペースト、やっとテキストファイルに保存することができました。

この後、動いているアプリをすべて終了させても(Firefoxはウィンドウの[X]ボタンでは終了しなかったのでタスクマネージャから終了させた)、PCのファンは騒音状態だったので、シャットダウンさせました。 いったい何だったんだろう?

まあ、こんな事態は数年に1回ですが、普段はもうちょっとマイナーな障害が発生します。
プログラミングネタのようにいろいろ資料を調査しながら、プログラムを作って動かして実験しながら書くときは、Webブラウザ上で編集モードのページを長時間開いたままとなります。気になるWebページはブラウザ上で開いて残しておくので、だいたいFirefoxのウィンドウが2,3枚あって、ウィンドウにそれぞれ10タブ位開いた状態で数日動かしています。

Webブラウザ(特に常用しているFirefoxは)は、長時間使っていると使用メモリが増大し、開いているページが増えると(あるいは特定のページヲ開くと)CPU使用率が増大し、文字入力のレスポンスが悪くなってくることがよくあります。開いてるページに仕込まれているJavaScriptが悪さをしているのではないかと思いますが、どれを閉じればよいかの判断がつきません。

傾向的には、IT系技術メディアのページが重いと感じますが、これはあまり広告がないブログページやAPIリファレンスページと比べての相対的な感覚かもしれません。

また、ノートPCで作業していると、日常生活のいろいろなイベントで頻繁に作業が中断し、スリープやハイバネート状態になっています。これが悪さをしている可能性もあります。

ということで、やっぱりWebブラウザは執筆ツールには厳しいなぁと思います。

Windows 10にはマイクロソフトの標準ブラウザEdgeが入っていますが、こっちは使っているとたまにプロセスが落ちる場合や、テキスト入力エリアでIMEが無反応(タスクバーのIMEのところが×印)になったり、半角英数とかな入力の切り替えができなくなったりということが割とあります。
日本語の文章を作成する場合、これはけっこう困りものです。Edgeはまだ執筆用のブラウザとして使うには信頼することができません。

Chromeはまだ使いこんでいないので(ノートPCには未インストール)、何とも言えません。

*1:Stream APIラムダ式で書かれたコードよりも、for文の方が分かりやすいではないか?との意見もありますが、for文で分かるのは、変数iをカウンタとして扱い、ループ条件とループ制御がどうなっているか(つまりhowが)一目瞭然ということであって、for文全体で処理していることは何か(what)を読み取ることはなかなかに難しいです。