y_uti のブログ

統計、機械学習、自然言語処理などに興味を持つエンジニアの技術ブログです

D3.js の配列操作関数いろいろ

ウェブブラウザ上でのデータ可視化手法を身につけようと思って D3.js の勉強を始めたのですが、API マニュアルを眺めていたところ、さまざまな配列操作関数が提供されていることに気付きました。これらの関数を使うことで、より関数的なコードを書くことができそうです。いくつかピックアップして紹介します。
Arrays · mbostock/d3 Wiki · GitHub

d3.sum は、配列の要素の和を取得する関数です。以下のように使います。

var data = [1, 2, 3, 4, 5];
var sum = d3.sum(data); // 15

この関数の面白いところは、第二引数に accessor を指定できるところです。accessor を指定すると、配列の各要素はまず accessor で処理された後に総和が計算されます。

var data = [1, 2, 3, 4, 5];
var squareSum = d3.sum(data, function (v) { return v * v; }); // 55

これは、配列を accessor で map した後に和を計算するのと意味的には同じです。実際、API マニュアルにも以下のように記載されています。

An optional accessor function may be specified, which is equivalent to calling array.map(accessor) before computing the sum.

https://github.com/mbostock/d3/wiki/Arrays#wiki-d3_sum

ただし、該当箇所のソースコードを参照してみたところ、内部的には本当に map しているわけではなく、メモリを無駄使いせずに和の計算が行われるように実装されていました。
d3/src/arrays/sum.js at v3.4.2 · mbostock/d3 · GitHub

d3.zip 関数と組み合わせると、配列をベクトルに見立てて内積や距離も簡単に計算できます。

var a = [1, 2, 3];
var b = [4, 5, 6];
var innerProduct = d3.sum(d3.zip(a, b), function (ab) { return ab[0] * ab[1]; }); // 32
var distance = Math.sqrt(
  d3.sum(d3.zip(a, b), function (ab) { return Math.pow(ab[0] - ab[1], 2); })); // 5.196...

d3.sum の他に、d3.min, d3.max, d3.mean といった関数も提供されています。どれも d3.sum と同様に accessor を渡して使うこともできます。

d3.shuffle は配列をランダムに並べ替える関数です。この関数は渡された配列を破壊的に操作します。

var data = [1, 2, 3, 4, 5];
var data2 = d3.shuffle(data); // data も変更される

非破壊的に並び替えたいときは、d3.range 関数でインデックスの列を生成し、これを shuffle した後に d3.permute で置換します。以下のように書けます。

var data = [1, 2, 3, 4, 5];
var data2 = d3.permute(data, d3.shuffle(d3.range(data.length))); // data は変更されない

permute という名前から数学的な置換をイメージしてしまいますが、d3.permute では、第二引数に渡すインデックス列の長さが第一引数の配列長と一致していなくても構いませんし、同じ要素を複数回含むこともできます。したがって、たとえば、配列からのランダムサンプリングを以下のように実装できます。

var data = [1, 2, 3, 4, 5];
var sample = d3.permute(data, d3.shuffle(d3.range(data.length)).slice(0, 3)); // data から 3 個選ぶ

最後に d3.nest を使ってみます。これは面白い関数で、配列の要素を特定の条件でグループ化して階層構造を作ることができます。csv ファイル等からデータを読み込んで集計、可視化するといった用途に便利に使えます。

まず、使い方を紹介するためのサンプルデータを作成します。次のコードでは、年齢と性別を属性に持つ 100 要素の配列をランダムに作成しています。

var data = d3.range(100).map(function () {
    return { "age": Math.floor(Math.random() * 100), "sex": Math.random() < 0.5 ? "M" : "F" };
});

// [
//   {"age":97,"sex":"M"},{"age":18,"sex":"F"},{"age":16,"sex":"M"},{"age":42,"sex":"F"},
//   {"age":70,"sex":"F"},{"age":41,"sex":"F"},{"age":68,"sex":"F"},{"age":65,"sex":"M"},
//     ...
//   {"age":30,"sex":"M"},{"age":96,"sex":"M"},{"age":24,"sex":"F"},{"age":79,"sex":"F"}
// ]

たとえば、このデータを性別でグループ化するには、以下のように書きます。entries に渡された data の各要素に key に指定された関数が適用され、その値によってデータがグループ化されます。

var nest = d3.nest()
    .key(function (d) { return d.sex; })
    .entries(data);

// [
//   {"key":"M","values":[
//     {"age":97,"sex":"M"},{"age":16,"sex":"M"},{"age":65,"sex":"M"},{"age":45,"sex":"M"},
//       ...
//     {"age":25,"sex":"M"},{"age":78,"sex":"M"},{"age":30,"sex":"M"},{"age":96,"sex":"M"}]},
//   {"key":"F","values":[
//     {"age":18,"sex":"F"},{"age":42,"sex":"F"},{"age":70,"sex":"F"},{"age":41,"sex":"F"},
//       ...
//     {"age":82,"sex":"F"},{"age":60,"sex":"F"},{"age":24,"sex":"F"},{"age":79,"sex":"F"}]}
// ]

sortValues 関数を追加すると、各要素をソートして出力することもできます。

var nest = d3.nest()
    .key(function (d) { return d.sex; })
    .sortValues(function (a, b) { return d3.ascending(a.age, b.age); })
    .entries(data);

// [
//   {"key":"M","values":[
//     {"age":1,"sex":"M"},{"age":1,"sex":"M"},{"age":4,"sex":"M"},{"age":8,"sex":"M"},
//     {"age":8,"sex":"M"},{"age":12,"sex":"M"},{"age":14,"sex":"M"},{"age":16,"sex":"M"},
//   ...
// ]

key 関数を複数指定すると、指定された順番でネストした階層構造が出力されます。次のコード例では、性別、年齢層 (0 代, 10 代, 20 代, ...) でグループ化しています。年齢層については、sortKeys 関数を指定して昇順に出力させています。sortKeys 関数は、そこまでに追加された key のうち最後のものに対する指定になります。

var nest = d3.nest()
    .key(function (d) { return d.sex; })
    .key(function (d) { return Math.floor(d.age / 10) * 10; }).sortKeys(d3.ascending)
    .sortValues(function (a, b) { return d3.ascending(a.age, b.age); })
    .entries(data);

// [
//   {"key":"M","values":[
//     {"key":"0","values":[{"age":1,"sex":"M"},{"age":1,"sex":"M"},{"age":4,"sex":"M"},{"age":8,"sex":"M"},{"age":8,"sex":"M"}]},
//     {"key":"10","values":[{"age":12,"sex":"M"},{"age":14,"sex":"M"},{"age":16,"sex":"M"}]},
//     {"key":"20","values":[{"age":25,"sex":"M"},{"age":28,"sex":"M"},{"age":29,"sex":"M"}]},
//   ...
// ]

グループ化されたデータを集計するには、rollup 関数を使います。グループ化された末端の各配列に関数が適用され、その戻り値が出力されます。以下の例では、性別、年齢層ごとの人数を出力します。

var nest = d3.nest()
    .key(function (d) { return d.sex; })
    .key(function (d) { return Math.floor(d.age / 10) * 10; }).sortKeys(d3.ascending)
    .rollup(function (values) { return values.length; })
    .entries(data);

// [
//   {"key":"M","values":[
//     {"key":"0","values":5},
//     {"key":"10","values":3},
//     {"key":"20","values":3},
//   ...

entries 関数の代わりに map 関数を使うと、"key", "values" という形式ではなく d3.map としてデータが出力されます。

var nest = d3.nest()
    .key(function (d) { return d.sex; })
    .key(function (d) { return Math.floor(d.age / 10) * 10; }).sortKeys(d3.ascending)
    .rollup(function (values) { return values.length; })
    .map(data);

// {
//   "M":{"0":5,"10":3,"20":3,"30":7,"40":4,"50":4,"60":2,"70":6,"80":4,"90":6},
//   "F":{"0":3,"10":6,"20":5,"30":4,"40":7,"50":7,"60":7,"70":10,"80":5,"90":2}
// }