Help us understand the problem. What is going on with this article?

Go言語で扱えるデータフレーム厳選4つ

はじめに

データサイエンティストでなかったとしても、数値データを使って様々な解析をする際には 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 を読み込み、不必要な commentid のカラムを消し、agename のカラムだけを残して 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())
}

とても直感的ですね。例えば ToCSVToJSON に変更すると以下の様に出力されます。

[
  {
    "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 が含まれています。

SIMD
引用: https://arrow.apache.org/

要はデータをカラムで持ち(カラム指向またはカラムナーと言います。えっ言わない?ちょっとした事で絡むな~)、データを扱いやすくしている訳です。さらにサイズを固定化してフラットにする事で、オフセットによる瞬時なアクセスを可能にしています。

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

https://github.com/rocketlaunchr/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 さんが分かりやすく解説してくれた記事があります。

https://qiita.com/thimi0412/items/05cff32279973b0d5599

ただし記事の中で登場する 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

https://github.com/mattn/go-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")
}

example.png

もちろん Jupyter Notebook からGo言語を扱える gophernotes からでもこの go-pairplot は扱える様にしてあります。

image.png

また go-plotlib を使うと Jupyter Notebook 上で CSV を簡単に表示できます。

https://github.com/mattn/go-plotlib

image.png

Go言語でデータサイエンスをする際はお役立て下さい。

まとめ

Go言語で扱えるデータフレームライブラリを4つご紹介しました。それぞれに特色があり、やりたい処理によって選ぶ必要があります。小規模から中規模のデータで、カラムを自在に操りたい場合は QFrame または Gota、巨大なデータを高速に扱いたいのであれば Apache Arrow と bullseye の組み合わせという選択になると思います。

中規模までのデータに関しては、パフォーマンスを求めるのであれば QFrame が良いです。Gota は Go の struct binding もサポートしていますし、おそらく皆さんがやりたいと思うであろう機能が一通りそろっているので、データフレームがどんな物か触ってみたいという方はまず Gota を触ってみるのをオススメします。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした