はじめに
データサイエンティストでなかったとしても、数値データを使って様々な解析をする際には CSV ファイル等ファイルを読み込み、数値の配列としてメモリに保持して、それらをループ等で利用して解析を行っておられると思います。
その際、配列は1次元目に行、2次元目に列、を格納するのが一般的です。多くのケースではこの方法で事足りるのですが、解析を行ううちに「列としてデータの固まりを扱いたい」「ラベル付けされた列を扱いたい」と感じる事が出てくると思います。
これを簡単にしてくれるのが「データフレーム」です。
データフレーム4種
本記事では Go 言語から扱えるデータフレームを4つご紹介します。
QFrame
QFrame は、フィルタリング、集計、およびデータ操作をサポートするイミュータブルなデータフレームです。 QFrame での操作は、それ自身が新しい QFrame となり元の QFrame は変更されません。データの多くがこの2つのフレーム間で共有されるためかなり効率的に実行できます。
QFrame は色々なケースで扱えるとても便利なライブラリです。CSV、SQL、JSON が扱え、しかも透過的に処理する事ができます。
例えばこんな JSON を扱う事も多いと思います。
[
{"id": 1, "name": "bob", "comment": "I like sushi", "age": 23},
{"id": 2, "name": "mike", "comment": "Raspberry Pi is good", "age": 47},
{"id": 3, "name": "john", "comment": "My mobile phone is Android", "age": 19},
{"id": 4, "name": "elvis", "comment": "I push the commit to GitHub", "age": 31}
]
この JSON を読み込み、不必要な comment
と id
のカラムを消し、age
と name
のカラムだけを残して age
でソートされた CSV を出力する場合、皆さんだとどの様に処理するでしょうか。QFrame であればとても簡単です。
package main
import (
"bytes"
"fmt"
"log"
"strings"
"github.com/tobgu/qframe"
)
const s = `
[
{"id": 1, "name": "bob", "comment": "I like sushi", "age": 23},
{"id": 2, "name": "mike", "comment": "Raspberry Pi is good", "age": 47},
{"id": 3, "name": "john", "comment": "My mobile phone is Android", "age": 19},
{"id": 4, "name": "elvis", "comment": "I push the commit to GitHub", "age": 31}
]
`
func main() {
newQf := qframe.ReadJSON(strings.NewReader(s))
var buf bytes.Buffer
err := newQf.Drop("comment", "id").Sort(qframe.Order{Column: "age"}).ToCSV(&buf)
if err != nil {
log.Fatal(err)
}
fmt.Println(buf.String())
}
とても直感的ですね。例えば ToCSV
を ToJSON
に変更すると以下の様に出力されます。
[
{
"age": 19,
"name": "john"
},
{
"age": 23,
"name": "bob"
},
{
"age": 31,
"name": "elvis"
},
{
"age": 47,
"name": "mike"
}
]
※整形しています
この様に、与えられた入力に対してとても直感的な操作が QFrame のウリです。
Apache Arrow & bullseye
Apache Arrow はマルチプラットフォームで動作するメモリ内データ向けのライブラリです。ハードウェア上で効率的な分析操作を行える様に設計されており、メモリが断片化しないよう工夫されています。現在サポートされている言語には、C、C ++、C#、Go、Java、JavaScript、MATLAB、Python、R、Ruby、および Rust が含まれています。
要はデータをカラムで持ち(カラム指向またはカラムナーと言います。えっ言わない?ちょっとした事で絡むな~)、データを扱いやすくしている訳です。さらにサイズを固定化してフラットにする事で、オフセットによる瞬時なアクセスを可能にしています。
bullseye は Apache Arrow で構築されたメモリを扱う為のデータフレームです。
Apache Arrow はデータの型をとても意識したライブラリです。データを投入するにはまずスキーマを定義する必要があります。例えば iris のデータセットであれば以下の定義になります。
schema := arrow.NewSchema([]arrow.Field{
{
Name: "sepal_length",
Type: arrow.PrimitiveTypes.Float32,
Nullable: false,
},
{
Name: "sepal_width",
Type: arrow.PrimitiveTypes.Float32,
Nullable: false,
},
{
Name: "petal_length",
Type: arrow.PrimitiveTypes.Float32,
Nullable: false,
},
{
Name: "petal_width",
Type: arrow.PrimitiveTypes.Float32,
Nullable: false,
},
{
Name: "species",
Type: arrow.BinaryTypes.String,
Nullable: false,
},
}, nil)
データを投入する際はメモリプールを生成して読み込みます。iris のデータを読み込み、種別を一覧するコードは以下の様になります。
package main
import (
"fmt"
"log"
"os"
"github.com/apache/arrow/go/arrow"
"github.com/apache/arrow/go/arrow/csv"
"github.com/apache/arrow/go/arrow/memory"
"github.com/go-bullseye/bullseye/dataframe"
"github.com/go-bullseye/bullseye/iterator"
)
func main() {
schema := arrow.NewSchema([]arrow.Field{
{
Name: "sepal_length",
Type: arrow.PrimitiveTypes.Float32,
Nullable: false,
},
{
Name: "sepal_width",
Type: arrow.PrimitiveTypes.Float32,
Nullable: false,
},
{
Name: "petal_length",
Type: arrow.PrimitiveTypes.Float32,
Nullable: false,
},
{
Name: "petal_width",
Type: arrow.PrimitiveTypes.Float32,
Nullable: false,
},
{
Name: "species",
Type: arrow.BinaryTypes.String,
Nullable: false,
},
}, nil)
f, err := os.Open("iris.csv")
if err != nil {
log.Fatal(err)
}
defer f.Close()
r := csv.NewReader(
f, schema,
csv.WithComment('#'),
csv.WithComma(','),
csv.WithHeader(),
csv.WithChunk(-1),
)
defer r.Release()
r.Next()
pool := memory.NewGoAllocator()
df, err := dataframe.NewDataFrame(pool, schema, r.Record().Columns())
if err != nil {
log.Fatal(err)
}
iter := iterator.NewStringValueIterator(&df.SelectColumns("species")[0])
m := map[string]int{}
for iter.Next() {
s := iter.ValueInterface().(string)
if _, ok := m[s]; !ok {
m[s] = len(m) + 1
}
}
fmt.Println(m)
}
ちょっと冗長な感じがしますが、例えば不正なデータが投入されるかもしれない入力データを扱うには便利です。また Apache Arrow はメモリブロックを必要な単位で切り出してフレームを構成しているので、断片化が起きにくく処理速度も高速です。大量のデータを扱うのであれば Apache Arrow と bullseye を使うのが良いと思います。ただし癖が強いので慣れるまでが大変です。
dataframe-go
dataframe-go も直感的な操作を売りにしています。まだ安定版ではないとの事ですが、以下の特徴があります。
- CSV, JSONL, MySQL, PostgreSQL からのインポート
- CSV, JSONL, MySQL, PostgreSQL へのエクスポート
- 開発しやすさ
- カスタムシリーズを作れるなどのフレキシブルさ
- パフォーマンス優先
- gonum との相互運用性
API は Select が無いなど、幾分物足りない感がありますが、RDBMS や gonum との連携がウリの様なので今後に期待したい所です。
package main
import (
"bytes"
"context"
"fmt"
"log"
"strings"
"github.com/rocketlaunchr/dataframe-go"
"github.com/rocketlaunchr/dataframe-go/exports"
"github.com/rocketlaunchr/dataframe-go/imports"
)
const s = `
{"id": 1, "name": "bob", "comment": "I like sushi", "age": 23}
{"id": 2, "name": "mike", "comment": "Raspberry Pi is good", "age": 47}
{"id": 3, "name": "john", "comment": "My mobile phone is Android", "age": 19}
{"id": 4, "name": "elvis", "comment": "I push the commit to GitHub", "age": 31}
`
func main() {
df, err := imports.LoadFromJSON(context.Background(), strings.NewReader(s))
if err != nil {
log.Fatal(err)
}
var buf bytes.Buffer
err = df.RemoveSeries("comment")
if err != nil {
log.Fatal(err)
}
err = df.RemoveSeries("id")
if err != nil {
log.Fatal(err)
}
df.Sort([]dataframe.SortKey{{Key: "age"}})
exports.ExportToCSV(context.Background(), &buf, df)
fmt.Println(buf.String())
}
dataframe はそのまま fmt.Println
すると綺麗な表になってくれます。
+-----+--------+--------+--------+-----------------------------+
| | AGE | ID | NAME | COMMENT |
+-----+--------+--------+--------+-----------------------------+
| 0: | 23 | 1 | bob | I like sushi |
| 1: | 47 | 2 | mike | Raspberry Pi is good |
| 2: | 19 | 3 | john | My mobile phone is Android |
| 3: | 31 | 4 | elvis | I push the commit to GitHub |
+-----+--------+--------+--------+-----------------------------+
| 4X4 | STRING | STRING | STRING | STRING |
+-----+--------+--------+--------+-----------------------------+
※ Gota や qframe も同様です
Gota
Gota は DataFrames、Series および data wrangling (マッピング等の加工)メソッド群の実装です。とても分かりやすく直感的で、これら4つの中では一番 Go らしいライブラリだと思います。Gota の操作については、@thimi0412 さんが分かりやすく解説してくれた記事があります。
ただし記事の中で登場する Gota のリポジトリは現在 github.com/kniren/gota
から github.com/go-gota/gota
に変更になっているのでご注意下さい。
僕はこれまで Gota をよく使ってきました。JSON や CSV を読み込む機能は他のデータフレームにもありますが、特に struct binding が便利なので、既存のコードにデータフレームを使った処理を追加する際に Gota は威力を発揮します。例えば User struct の配列から欲しいフィールドだけを集めて JSON にするのであれば以下の様になります。
package main
import (
"bytes"
"fmt"
"log"
"github.com/go-gota/gota/dataframe"
)
type User struct {
Name string
Age int
Accuracy float64
ignored bool // ignored since unexported
}
func main() {
users := []User{
{"Aram", 17, 0.2, true},
{"Juan", 18, 0.8, true},
{"Ana", 22, 0.5, true},
}
df := dataframe.LoadStructs(users)
var buf bytes.Buffer
err := df.Select([]string{"Age", "Name"}).WriteJSON(&buf)
if err != nil {
log.Fatal(err)
}
fmt.Println(buf.String())
}
また iris の CSV を読み込んで種別を数値化(Bug of words)するのであれば以下の様になります。
package main
import (
"fmt"
"log"
"os"
"github.com/go-gota/gota/dataframe"
"github.com/go-gota/gota/series"
)
func main() {
f, err := os.Open("iris.csv")
if err != nil {
log.Fatal(err)
}
defer f.Close()
df := dataframe.ReadCSV(f)
yDF := df.Select("species").Capply(func(s series.Series) series.Series {
records := s.Records()
floats := make([]float64, len(records))
m := map[string]int{}
for i, r := range records {
if _, ok := m[r]; !ok {
m[r] = len(m)
}
floats[i] = float64(m[r])
}
return series.Floats(floats)
})
fmt.Println(yDF.String())
}
ベンチマーク
上記のデータフレームライブラリの内、QFrame と Gota でベンチマークを取ってみました。Apache Arrow および bullseye は CSV を読み込む機能こそ用意されていますが、作ったレコードを再度 CSV に出力する為の機能が備わっておらず、自らレコードを作り直さないといけなかった為、このベンチマークからは除外しました。
package df_test
import (
"bytes"
"context"
"io/ioutil"
"testing"
"github.com/go-gota/gota/dataframe"
"github.com/rocketlaunchr/dataframe-go/exports"
"github.com/rocketlaunchr/dataframe-go/imports"
"github.com/tobgu/qframe"
)
func BenchmarkQFrame(b *testing.B) {
bs, err := ioutil.ReadFile("iris.csv")
if err != nil {
b.Fatal(err)
}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
var out bytes.Buffer
qf := qframe.ReadCSV(bytes.NewReader(bs))
qf = qf.Select("sepal_length", "species")
err = qf.ToCSV(&out)
if err != nil {
b.Fatal(err)
}
}
}
func BenchmarkGota(b *testing.B) {
bs, err := ioutil.ReadFile("iris.csv")
if err != nil {
b.Fatal(err)
}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
var out bytes.Buffer
df := dataframe.ReadCSV(bytes.NewReader(bs))
df = df.Select([]string{"sepal_length", "species"})
err = df.WriteCSV(&out, dataframe.WriteHeader(true))
if err != nil {
b.Fatal(err)
}
}
}
func BenchmarkDataframeGo(b *testing.B) {
bs, err := ioutil.ReadFile("iris.csv")
if err != nil {
b.Fatal(err)
}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
var out bytes.Buffer
df, err := imports.LoadFromCSV(context.Background(), bytes.NewReader(bs))
if err != nil {
b.Fatal(err)
}
df.RemoveSeries("sepal_width")
df.RemoveSeries("petal_length")
df.RemoveSeries("petal_width")
err = exports.ExportToCSV(context.Background(), &out, df)
if err != nil {
b.Fatal(err)
}
}
}
goos: windows
goarch: amd64
pkg: github.com/mattn/misc/df-benchmark
BenchmarkQFrame-4 9998 120531 ns/op 59520 B/op 426 allocs/op
BenchmarkGota-4 3636 374609 ns/op 150064 B/op 2401 allocs/op
BenchmarkDataframeGo-4 3529 339492 ns/op 131101 B/op 4561 allocs/op
PASS
ok github.com/mattn/misc/df-benchmark 5.067s
結果は Gota や dataframe-go よりも QFrame が2~3倍ほど速い結果になりました。この要因として、QFrame が出力する CSV の形式が
2.3,setosa
の様な数値の形式であるのに比べ Gota は
2.300000,setosa
の様な若干冗長な形式になっているのも起因してる様です。dataframe-go が遅いのはソースを見たところ RemoveSeries でのコピー量が多い様です。
pairplot
これはデータフレームではありませんが、Python の seaboan.pairplot をGo言語に移植した go-pairplot というライブラリがあり、先日これを Gota を使う様に変更しました。以前までは扱える型を float64 に限定していた為、精度の必要ないデータや文字列を扱うデータでは使いどころが難しかったのですが、データフレームを使う様に変更した事でとても便利になりました。
package main
import (
"log"
"os"
"github.com/go-gota/gota/dataframe"
"github.com/mattn/go-pairplot"
"gonum.org/v1/plot"
"gonum.org/v1/plot/vg"
)
func main() {
p, err := plot.New()
if err != nil {
log.Fatal(err)
}
f, err := os.Open("iris.csv")
if err != nil {
log.Fatal(err)
}
defer f.Close()
df := dataframe.ReadCSV(f)
pp, err := pairplot.NewPairPlotDataFrame(df)
if err != nil {
log.Fatal(err)
}
pp.SetHue("Name")
p.HideAxes()
p.Add(pp)
p.Save(8*vg.Inch, 8*vg.Inch, "example.png")
}
もちろん Jupyter Notebook からGo言語を扱える gophernotes からでもこの go-pairplot は扱える様にしてあります。
また go-plotlib を使うと Jupyter Notebook 上で CSV を簡単に表示できます。
Go言語でデータサイエンスをする際はお役立て下さい。
まとめ
Go言語で扱えるデータフレームライブラリを4つご紹介しました。それぞれに特色があり、やりたい処理によって選ぶ必要があります。小規模から中規模のデータで、カラムを自在に操りたい場合は QFrame または Gota、巨大なデータを高速に扱いたいのであれば Apache Arrow と bullseye の組み合わせという選択になると思います。
中規模までのデータに関しては、パフォーマンスを求めるのであれば QFrame が良いです。Gota は Go の struct binding もサポートしていますし、おそらく皆さんがやりたいと思うであろう機能が一通りそろっているので、データフレームがどんな物か触ってみたいという方はまず Gota を触ってみるのをオススメします。