kawasin73のブログ

技術記事とかいろんなことをかくブログです

ネストした JSON を CSV に自動変換する Python ライブラリを作った

プログラマーあるある、なにかと独自のミニ言語を作りがち。どうも、かわしんです。どうしても簡潔にやりたいことを表現するためにミニ言語つくりがちですよね。JSON で構文作ると長いし。

さて、ACES Inc. という東大の松尾研究室発の AI ベンチャーがあるのですが、そこの創業者メンバーと学科の同期だったので最近お手伝いしながらアーキテクチャ設計をしたり Python をゴリゴリ書いたりしています。ちなみに僕はディープラーニング機械学習もしてないです。

その中で推論結果が dictlist を組み合わせたデータ構造で返ってくるのですが、それを JSONCSV の両方で保存したいという仕様がありました。JSON への変換は json.dumps() を使えば一発なので自明ですが、CSV への変換は list が含まれた時にそれをどのように展開するかが自明ではありません。

推論アルゴリズムはたくさんあってそれごとに CSV への変換コードを書くのは効率的でないので、CSV へ自動で変換するライブラリを実装しました。

github.com

特徴としては、以下のようなものが挙げられます。

  • 配列を CSV の複数行に展開する
  • 展開された配列のインデックス番号の出力に対応
  • データ構造を解析して CSV の列情報 fieldnames を自動で生成

例としては以下のような感じです。これで大体の使い方を感じ取ってください。

import io
from nested_csv import NestedDictWriter
data = [
  {"hello": {"world": "value0"}, "list": [[1,2,3], [4,5,6]], "fixed": [1,2]},
  {"hello": {"world": "value1"}, "list": [[7,8], [10,11]], "fixed": [3,4]},
]
fieldnames = ['hello.world', 'list[id]', 'list[][id]', 'list[][]', 'fixed[1]']
file = io.StringIO()
w = NestedDictWriter(file, fieldnames)
w.writeheader()
w.writerows(data)
file.seek(0)
file.read()
# hello.world,list[id],list[][id],list[][],fixed[1]
# value0,0,0,1,2
# value0,0,1,2,2
# value0,0,2,3,2
# value0,1,0,4,2
# value0,1,1,5,2
# value0,1,2,6,2
# value1,0,0,7,4
# value1,0,1,8,4
# value1,1,0,10,4
# value1,1,1,11,4

既存のライブラリ

まず、Python には標準の csv パッケージがあります。csv パッケージには csv.writercsv.DictWriter が用意されています。

csv — CSV File Reading and Writing — Python 3.8.0 documentation

csv.writer は各行を writerow() メソッドにデータのリストを渡すことで出力します。リストの各要素が各列の要素に対応します。

csv.DictWriter は辞書のキーの一覧のリスト(fieldnames)を渡して初期化し、各行を writerow() メソッドにデータの辞書(dict)を渡すことで出力します。各列の順序は初期化時に渡したキーのリストの順序になります。

しかし、残念ながら csv.DictWriter はネストした辞書形式に対応していません。そこでネストしたデータ構造に対応しているものを Github で探すと、あるにはあるのですがネストした辞書をフラットな辞書に変換する程度のことしかしていなくて、辞書に含まれた リストの対応 が考慮されていません。

そこでネストした辞書にも辞書に含まれたリストにも対応する CSV 変換パッケージを作ることにしました。

nested_csv パッケージ

github.com

インターフェイスとしては、csv パッケージの csv.DictWriter を踏襲して __init__() writerow() writerows() writeheader() を同じような引数体系にしました。ただし、csv.DictWriter は継承するのではなく内部にインスタンスを持って利用し、ネストしたデータ構造をフラットな辞書形式に変換して csv.DictWriter.writerow() に渡すという方針にしました。

一番の特徴としてはネストしたリストに対応するということです。ネストしたリストをどのように出力するかはかなり悩みましたが、 POSIX 原理主義過激派 の方が書かれた JSONシェルスクリプトでパースする以下の記事の考え方を参考にして、SQL でいう OUTER JOIN のような形で 1 つのデータを複数行に展開 するようにしました。

jq、xmllintコマンドさようなら。俺はパイプが好きだから - Qiita

リストを扱うために、[][id][<number>] の 3 つの文法を導入しました。詳しい文法の中身は後述します。

また、データの型をリスト fieldnames にして初期化するわけですが、いちいちこの fieldnames を自分の手で設定するのは面倒臭いです。ここは自動化できるため generate_fieldnames() という関数を用意して実際のデータを 1 つ渡すと構造を解析して自動で fieldnames を生成するようにしました。

あと、Python 3.5 3.6 3.7 で正しく動くことを確認しています。また、全て標準パッケージ で実装されているので余計なライブラリのインストールは必要ありません。

fieldnames の記法

generate_fieldnames() を利用すれば自動で fieldnames を生成できますが、以下の2つの場合には CSV への変換フォーマットを設定する必要があります。

  • 推論結果に配列が含まれる場合
    • 配列の要素数 0 要素の時に自動型生成がされない
  • CSV の列の順序に意味がある時
    • 型自動生成では、各キーを文字列の辞書順に並べて生成する

これらに当てはまる時は、fieldnames となる文字列のリストを指定する必要があります。その記法は以下の通りです。

オブジェクトのキー

  • オブジェクトの各キーは、. (ドット)区切りで指定する
JSON : {"a": {"b": 1, "c": 2}}
fieldnames : ["a.b", "a.c"]
--- CSV ---
1,2

素数の固定された配列

  • 素数の固定された配列は、[<index>] で指定する
JSON : {"a": [1,2], "b": [0]}
fieldnames : ["a[0]", "a[1]", "b[0]"]
--- CSV ---
1,2,0

素数不定な配列

  • 素数不定な配列は、[] で指定する
    • [] の配列の要素は CSV での複数の行に展開されて表示される
    • 1 つのフィールドに複数の [] を指定することもでき、その場合はそれぞれの全ての組み合わせが CSV の行に展開される
    • 複数のフィールド間で配列の位置の整合性を取る必要がある。以下の例はエラーになる
      • "a[]" + "a.b" : 配列表現とキー表現で不整合
      • "a[]" + "b.c[]" : 配列が異なる階層にある場合(CSV への展開が論理的にできないため)
      • "a[][]" + "b[].c[]" : 配列が異なる階層にある場合(全ての配列が同じ階層である必要がある)
    • 異なるフィールドで同じ階層に配列がある場合は、それらのフィールドの中で最長の配列の長さの回数 CSV に展開される。その際短い配列の中身は `` (空文字)として出力される
JSON : {"a": [[{"x": 1}], [{"x": 2}, {"x": 3}]], "b": [4, 5, 6], "c": [[7, 8, 9]]}
fieldnames : ["a[][].x", "b[]", "c[][]"]
--- csv ---
1,4,7
,4,8
,4,9
2,5,
3,5,
,5,
,6,
,6,
,6,

素数不定な配列のインデックス番号

  • [] で指定した配列の順序を [id] で出力することができる
    • ただし、前項の [] で登録されていない配列に対する [id] を指定することはできない
    • また、前項の [] と整合性の取れない [id] を指定することはできない
JSON: {"a": [[1,2,3], [4,5]}
fieldnames : ["a[id]", "a[][id]", "a[][]"]
--- csv ---
0,0,1
0,1,2
0,2,3
1,0,4
1,1,5
1,2,

工夫した点

工夫した点は 3 点あります。

1 点目は、内部での型情報の構造の持ち方です。fieldnamesインスタンスの初期化時にコンパイルされて、構造の整合性のチェックを行った上で writerow() で参照しやすいような形に変換されます。これによって複数回呼ばれる writerow() で素早く CSV を出力できるようにしています。

そのためにリストのためのデータ構造が 摩訶不思議なもの になってしまいました。全部タプルとリストでデータ構造を作ったからで適切な名前をつければマシになるとは思いますが、辞書とかクラスとかはなんかパフォーマンスが悪くなりそうなので使ってません。

2 点目は、複数ネストしたリストの展開です。リストが何段ネストするかはわからないため、可変の深さの for 文を実装する必要があります。これ、スタックとか使って自力で実装するのめんどくさいなと思ってましたが、Python には itertools.product という便利な Util 関数があり、これを使って実現できました。

3 点目は、必要のない展開処理をスキップするようにしたことです。可変長のリストが含まれる場合は、シンプルな辞書の値は複数行に渡って同じものが出力されます。また、ネストしたリストを出力する場合も深いリストを出力している間は浅いリストの値は変わりません。そのため、複数の出力行に渡って 値のキャッシュ current を持っておき、更新するリストの値だけを変更して無駄な代入をスキップするようにしました。

最後に

機械学習の分野では依然として CSV ファイルでの出力が好まれる現場があるそうです。複雑な構造の JSON から CSV に変換するのって結構悩みがちですが、このパッケージを使えば楽に自動で変換することができるので、ぜひ使っていただきたいです。

また、将来への TODO として、CSV を読み取る NestedDictReader も作れればいいかなという野望もあります。

以前 Twilter を作った時も独自のフィルター言語を策定して実装しましたが、こういう単一目的に特化した小さな文法って結構便利なんですよね・・・。

Twitter をフィルタリングする Twilter を作った - kawasin73のブログ

現場からは以上です。