[C++]データ処理、分析のためのライブラリ制作。

1. はじめに

以前からブログやX(旧Twitter)でしばしば触れていることではあるが、私はC++でデータ集計や分析をするためのライブラリを制作している。今回、そのライブラリの最新版がいくらか動作するようになったので、GitHubでの公開とともにこのブログでも紹介しようと思い、これを書いている。興味のある人はちょっと覗いてみてほしい。そしてあわよくば、誰か開発に参加してほしい。

本記事で紹介するライブラリ ADAPT は、概ね次のようなものである。

  1. 階層構造を基本とするデータコンテナ。PandasのMultiIndexや階層型データベースに近いと思うが、同等ではない。
  2. SQLやPandasのJOINに似たコンテナ間の疑似結合に対応する。
  3. 各要素が持つフィールド(列みたいなもの)へのアクセスやそれらを用いた計算にはBoost.Lambdaなどに似たプレースホルダとラムダ関数1を用いる。一般的な演算子、数学関数を使える他、階層構造を利用した計算を行う特殊な関数なども。
  4. <ranges>に似た走査条件指定、変換などが可能。std::ranges::input_rangestd::ranges::viewable_rangeの要件を満たしている2ので、range-based for loopなどでも使えるし、場合によっては<ranges>と連携させることもできる3
  5. DTree限定ではあるが、ClingというC++インタプリタ上でも動作することを確認した。Pythonなどのように一行ずつ入力しながらの実行も可能である。Cling導入解説はこちら
  6. 現在のところヘッダオンリーである。

要するに、PythonならPandasが担うような処理を、純C++でも出来るようにしたかったのだ。ただし、NumPyやPandasを再現したとか、それらをC++で使えるようにしたとかではなく、別のコンセプトで新しいライブラリを作っている、という話である。C++標準ライブラリの<ranges>に寄せるように設計しているなど、使い方はNumPyやPandasとはかけ離れているので、それらとそっくりのツールを所望していた方には申し訳ないが期待に沿うことは出来ない。

それでもなお興味を持ってもらえるのなら、以下のGitHubリポジトリを覗いてみてほしい。

GitHub - thayakawa-gh/OpenADAPT: An open source data analysis and processing library for C++.

2. 使い方の例

上の箇条書きではよく分からないと思うので、使い方のイメージを簡単に載せておく。

例えば、ある中学校の3年1組の生徒の試験成績を収めたデータを作成してみる。

    using enum adapt::FieldType;
    adapt::DTree t;//std::vectorの入れ子のようなイメージ。
    t.SetTopLayer({ { "school", Str } });//-1層のフィールド。学校名。-1層は不要なら設定しなくても良い。
    t.AddLayer({ { "grade", I08 }, { "class", I08 } });//0層のフィールド。学年とクラス。
    t.AddLayer({ { "number", I16 }, { "name", Str } });//1層のフィールド。生徒の出席番号と名前。
    t.AddLayer({ { "exam", I08 },  {"jpn", I32}, {"math", I32}, {"eng", I32}, {"sci", I32}, {"soc", I32 } });//2層のフィールド。五教科の試験成績。
    t.VerifyStructure();

    t.SetTopFields("胴差県立散布流中学校");

    t.Reserve(1);//std::vector::reserveみたいなもの。
    t.Push((int8_t)3, (int8_t)1);//3年1組。

    auto cls_1 = t[0];//3年1組の要素への参照を取得。
    cls_1.Reserve(3);//3年1組に生徒3人分のスペースを用意(注2)。限界集落。
    cls_1.Push((int16_t)0, "濃伊田美衣子");//出席番号と名前を格納。std::vector::push_backみたいなもの。
    cls_1.Push((int16_t)1, "角兎野誠人");
    cls_1.Push((int16_t)2, "子虚烏有花");

    auto dummy_dummy_ko = cls_1[0];//濃伊田美衣子の要素への参照。
    dummy_dummy_ko.Reserve(4);//前期中間、前期期末、後期中間、後期期末の順に4回分のスペースを用意(注2)。
    dummy_dummy_ko.Push((int8_t)0, 46, 10, 32, 3, 29);//国数英理社の成績。前期中間。
    dummy_dummy_ko.Push((int8_t)1, 65, 21, 35, 0, 18);//前期期末。
    dummy_dummy_ko.Push((int8_t)2, 50, 15, 44, 22, 37);//後期中間。
    dummy_dummy_ko.Push((int8_t)3, 48, 8, 24, 11, 26);//後期期末。

    auto kakuu_no_seito = cls_1[1];//角兎野誠人の要素への参照。
    kakuu_no_seito.Reserve(4);//前期中間、前期期末、後期中間、後期期末の順に4回分のスペースを用意(注2)。
    kakuu_no_seito.Push((int8_t)0, 76, 91, 88, 94, 69);//国数英理社の成績。前期中間。
    kakuu_no_seito.Push((int8_t)1, 80, 99, 74, 85, 71);//前期期末。
    kakuu_no_seito.Push((int8_t)2, 72, 95, 72, 93, 75);//後期中間。
    kakuu_no_seito.Push((int8_t)3, 84, 89, 77, 83, 82);//後期期末。

    auto shikyo_uyuu_ka = cls_1[2];//子虚烏有花の要素への参照。
    shikyo_uyuu_ka.Push((int8_t)0, 98, 71, 85, 64, 99);//国数英理社の成績。前期中間。
    shikyo_uyuu_ka.Push((int8_t)1, 86, 69, 91, 70, 100);//前期期末。
    shikyo_uyuu_ka.Push((int8_t)2, 90, 75, 78, 80, 92);//後期中間。
    shikyo_uyuu_ka.Push((int8_t)3, 94, 67, 81, 76, 96);//後期期末。

現時点で、データを格納するストレージはDTree/STreeの2種類があり、それぞれ動的/静的に階層構造と変数を定義する。階層構造と各フィールドの型をdynamicに指定できるDTreeの場合、値の型はFieldTypeに定義されているもの(整数、浮動小数点、複素数、文字列+α)に限られる。staticに決定するSTreeは(いくらかの制限のもとで)任意の型の値を格納でき、動作も多少高速であるが、一部機能に制約がある上、vectorにまとめたりもできない。使い方はほとんど同じなので、適宜使い分けると良い。

この中のデータには次のようにアクセスする。 ここではDTreeを動的型のまま扱っているので、str()など明示的なキャストが必要な場合があるが、静的型で扱う場合は不要である。

    auto [name, jpn, math, eng] = t.GetPlaceholders("name", "jpn", "math", "eng");
    auto dummy_dummy_ko = t[0][0];
    std::cout << dummy_dummy_ko[name].str() << std::endl;//濃伊田美衣子

    //プレースホルダからラムダ関数を作ることができる。
    using Lambda = adapt::eval::RttiFuncNode<adapt::DTree>;
    Lambda total_3subjs = jpn + math + eng;
    std::cout << total_3subjs(t, adapt::Bpos{ 0, 0, 1 }).i32() << std::endl;//121。濃伊田美衣子の前期期末の3科目合計点。

    Lambda exist_240 = exist(total_3subjs >= 240);
    for (auto& trav : t | Filter(exist_240) | GetRange(2_layer))
    {
        //3科目合計240点以上を取ったことのある生徒、
        //つまり角兎野誠人と子虚烏有花の、
        //各試験での3科目合計点が表示される。
        std::cout << std::format("{}: {}\n", trav[name].str(), total_3subjs(trav).i32());
        //角兎野誠人: 255
        //角兎野誠人: 253
        //角兎野誠人: 239   240点を下回っているが"一度でも240点以上を取ったことがあれば条件を満たす"ので、表示される。
        //角兎野誠人: 250
        //子虚烏有花: 254
        //子虚烏有花: 246
        //子虚烏有花: 243
        //子虚烏有花: 242
    }

    //前期中間試験で数学のクラス内最高点を取った生徒のインデックス、名前、点数を表示する。
    t | Filter(isgreatest(math.at(0))) | Show(name, math.at(0));//[   0,   1]  角兎野誠人     91

    //英語で35点未満を取った生徒の名前とその点数をフィールドとして持つ新しいDTreeを生成する。
    //eには1層に生徒名、その生徒の英語の最高点と最低点、2層に各試験の英語の点数が格納される。
    adapt::DTree e = t | Filter(eng < 35) | Extract(name, eng, greatest(eng), least(eng));
    auto [fld0, fld1, fld2, fld3] = e.GetPlaceholders("fld0", "fld1", "fld2", "fld3");
    e | Show(fld0, fld1, fld2, fld3);
    //[   0,   0,   0] 濃伊田美衣子          32          44          24
    //[   0,   0,   1] 濃伊田美衣子          24          44          24

    //Matplot++で後期中間試験の国語の点数をヒストグラムにする。
    matplot::figure_handle f = matplot::figure(true);
    std::vector<double> v_jpn = t | ToVector(cast_f64(jpn.at(2)).f64());
    matplot::hist(v_jpn);
    matplot::save("quickstart_dtree.png");

他にも多数のサンプルを用意している。ここに貼るとあまりに記事が長くなるので、代わりにこのリンク先のサンプルコード集を参照してほしい

3. 動作環境

ADAPTを利用するにはC++20以上が必要である。ヘッダオンリーなので、クローン等でファイルをコピーしOpenADAPTディレクトリをインクルードディレクトリに追加すればよい。あとは#include <OpenADAPT/ADAPT.h>とするだけである。 一応CMakeでインストールすることもでき、その場合はfind_packageも使える。また後ほどCMakeでの使い方を追記する。

動作確認はVisual Studio 2022、GCC-13、Clang-17で行った。

4. 実装したい機能や改善案など

  1. 現在実装されているJOINの方式はキーの一致によるものだけだが、他にも用意する。また1対多で連結するようなものも作成する。

  2. グループ化(何らかの条件によって要素をグループ分けする方法)はいずれ用意したい。ただ親子関係をどうするのかなど悩みどころがいくつかある。

  3. モジュール化、ないし一部だけでも事前にビルド可能にする。ヘッダオンリーであることも手伝ってかなりコンパイル時間が長くなっており、なんとかしたい。

  4. ラムダ関数をstd::stringの文字列数式から生成する機能を用意する。プレースホルダをいちいち取得する必要がなくなるし、ラムダ関数をアプリケーション外部からパラメータとして入力できるようになる。一般にはあまり使い道はないだろうが、将来的に私が利用する予定。

  5. ヒストグラム的な、ビンごとに切り分けるようなコンテナは私が頻繁に使うのでいずれ作る。離散値で括るグループ化とは異なり、連続値を何らかのビン幅で区切るような感じ。おそらく現在のTreeを拡張する形で作れるはず。

  6. パフォーマンスの改善。パフォーマンステストなども十分に行えておらず、まだ最大限のパフォーマンスを実現したとはとても言えない状態である。わざわざC++を使う以上速度は極めて重要であるので、少しずつだが改善する。マルチスレッド化やSIMDの導入も進めている。

  7. VTKによる3D可視化をいずれ作る。GUI付きの3Dビューアも旧バージョンでは作っていたので、そちらのベースライブラリを現ADAPTに置き換える形で開発する予定。ただし公開されるかは不明である。

  8. 新しいフィールドの追加や削除も可能にしたい。現在のSTree、DTreeはデータを要素(行のようなもの)ごとに保管しており、フィールド追加はその層の要素全てを書き換える必要があるため、非常にコストが大きく使い所に注意する必要があるが、出来ないのは流石に不便だ。

  9. 何かしらスクリプト的に実行する手段を用意できると嬉しい。ROOTそのものは大嫌いだがClingには興味があるので、このあたりをよく調べ、現実的に可能かを検討できたらいいなぁと思っている。Pythonに移植する手もあるが、Pythonは競合ツールが多すぎるしC++であるメリットをかなぐり捨てることになるので……。

  10. 連結コンテナは現状、DJoinedContainerのみが動作している。しかし、全ての連結層もstaticに与える場合、原理的には連結後階層構造の全てをStaticに決定するSJoinedContainerも定義可能なはずである。STreeの場合にCttiプレースホルダを取得しやすくなる、パフォーマンスが向上するなどのメリットがありうる。

  11. DTreeを一時的にファイルとして出力する方法。あくまで一時保存用で、他人へのデータ受け渡しなどに利用することは推奨しない。STreeでも不可能ではないが、原理的に、フィールドが全てtrivially_copyableである必要があるし、階層構造をユーザーが自前で定義する必要もあるので、あまり便利なものにはならないと思われる。

5. 実装未定、困難なもの

  1. CSVなど既存の形式のファイルを読み込むような機能は今のところ作成していない。もちろん簡易的なもので良ければ提供できるが、CSVくらいのちゃちなファイルならPythonでもなんでももっと便利なツールで扱えば良いだろう。ADAPTはメモリになんとか乗り切るGBのオーダーのデータを扱うことを想定しているので、CSVはそもそも考慮していない。

  2. 厳密に親子関係の定まった階層構造がベースなので、親子関係をぶち切るソートなどは仕様に悩むところで、未定。

  3. 高度な統計解析や機械学習などのアルゴリズムについては、私は使ったことこそあれども実装できるほどの知識はないので、素直にサードパーティのライブラリを使うほうが良いと思っている。dlibあたりは使いやすかった。

6. 閑話

如何せんようやく動作するようになったばかりのライブラリなので、バグは色々と残っていると思われる。パフォーマンスが十分かどうかも全くテストできていない。バージョン番号が2.0.0-alpha0となっているように、現状はあくまでアルファ版だ。果たしてこんなツールを使ってくれる人が出てくるのかは分からないが、もしバグ報告等があれば歓迎である。英語でも日本語でも構わないので、GitHubでIssueを立てて教えてほしい。特にJoinに関しては極めて状況が多様なため、テストプログラムだけではデバッグしきれないことが非常に多い。

バグに限らず、コード品質向上のための提案なども大変助かる。私はプログラミングについては専門ではなく独学であるため、知識の偏りからお粗末な設計になっている場所は多々ある。あるいは、例えば余計なコピーコンストラクタが動いている場所など、パフォーマンスに影響しかねないおかしなコードが潜んでいる可能性も大いにある。入力に対する出力が正しいかどうかはテストしているが、内部の細かな挙動を検証しきれていないのだ。

ビルドが阿呆ほど遅くなってしまったことも悩みのタネ。スクリプト言語的な応答性を求めるのなら、ADAPTは現状役に立たないだろう。何とか改善したいが、モジュール化するか、一部事前にビルドできるようにするとかしか思いつかない。

ちなみにメジャーバージョンが2なのは、一般に公開していない、研究室内のみで公開してきたバージョン0と1が存在するからだ。リポジトリ名がOpenADAPT4となっているのも、過去にプライベートで開発してきたバージョンとの区別のためである。このあたりの経緯についてはこちらの記事で語っている。あ、ただのポエムなので別に読まなくていいです。


  1. C++ラムダ式のことではない。プレースホルダを使って構築する関数のこと。ラムダ関数という呼び方はあまり適切でない気がするが、他に良い名称が思いつかなかった。
  2. ++--の双方に対応するなどstd::ranges::bidirectional_rangeに近い動作が可能であるが、原理的にいくつかの条件を満たすことが難しい。動的メモリ確保が頻発する可能性を許せば満たせないことはないが、決して好ましい仕様ではないので、std::ranges::input_rangeまでで断念した。
  3. ただし、<ranges>のviewを繋いでしまうとそれ以降ADAPT独自のviewなどを使うことが出来ない。
  4. プライベートリポジトリと対比するのならPublicADAPTが適切だと思わなくもないが、名前としてあんまりなので……