見出し画像

RedAmber - Rubyの新しいデータフレームライブラリ

(2023-05-18追記)
最新の情報に更新しました。

RedAmberとは?

RedAmberは、プログラミング言語Rubyで書かれたデータフレームライブラリです。

データフレームとは、SQLのテーブル、Excelのワークシートのようなひとまとまりの2次元データで、Pythonのpandasが扱うものというと分かる人が多いかもしれません。

残念なことに、Rubyはデータ処理の世界で使えるツールが充実しているとは言えません。それでも、データの可視化、機械学習、各種データの集積でRed Data Toolsプロジェクトが頑張っています。また、効率的に大量のデータをメモリー上で処理するためのフレームワークであるApache ArrowにはRuby実装であるRed Arrowがあり、大規模データ処理でRubyを使う環境は少しずつ整ってきています。

RedAmberはRed Arrowの機能を利用して、PythonのpandasやRのdplyr/tidyrでやるようなことをRubyらしい書き方でできるようにするためのライブラリです。

2022年度Rubyアソシエーション開発助成に採択されたプロジェクトとして開発を進めました。

インストール

必要なもの

  • Ruby 3.0以上

  • Arrow 11.0.0以上

    • Apache Arrowは今は頻繁に機能追加と改善を重ねていて約3ヶ月毎にメジャーバージョンアップしています。

    • 最低限、Apache Arrow本体とApache Arrow GLib(Cバインディング)を入れる必要があります。Ruby実装はC++実装のバインディングであるC GLib実装から自動変換で作られているからです(Apache Arrowの最新情報(2022年5月版))。

  • Red Arrow 11.0.0以上

  • RedAmber 0.4.2(2023年4月2日時点の最新版)以上

    • Red ArrowとRedAmberはRubyGemsから入れられます。

インストールの詳細は最新のGithubのREADMEを参照してください。私はmacOSまたはWindowsのWSL2上のUbuntuで使っています。

Dockerイメージ、またはBinderで

RubyData Docker Stacks に@mrknさんがRedAmberを入れてくださっていて、Dockerイメージから試してみる事ができます。また、Binder ではブラウザからRedAmberが動くJupyter Notebookを試す事ができます(起動に時間がかかるのと、アクセス回数に制限があるので運が良ければ)。

試してみる

ここではirb、またはirubyを有効にしたJupyter notebookで動かしたソース例と実行結果で紹介していきます。

今回はRed Data Toolsの`red-dataset-arrow`も必要ですので入れておいてください。Gemfileに最低限必要な行は次のようになります。

gem 'red-arrow', '~> 11.0.0'
gem 'red_amber'
gem 'red-datasets-arrow' # Optional, recommended if you use Red Datasets

ライブラリの読み込み

require 'red_amber' # require 'red-amber' でもOK
include RedAmber    # 名前が長いので include して使うのがお勧め

VERSION
=> 0.4.2

Penguinsデータの例

有名なPenguinsのデータをRのデータセットからお借りして処理してみましょう。

require 'datasets-arrow'

dataset = Datasets::Penguins.new
penguins = DataFrame.new(dataset)
penguins
=> 
#<RedAmber::DataFrame : 344 x 8 Vectors, 0x00000000000175e8>
    species  island    bill_length_mm bill_depth_mm flipper_length_mm ...     year
    <string> <string>        <double>      <double>           <uint8> ... <uint16>
  0 Adelie   Torgersen           39.1          18.7               181 ...     2007
  1 Adelie   Torgersen           39.5          17.4               186 ...     2007
  2 Adelie   Torgersen           40.3          18.0               195 ...     2007
  3 Adelie   Torgersen          (nil)         (nil)             (nil) ...     2007
  4 Adelie   Torgersen           36.7          19.3               193 ...     2007
  : :        :                      :             :                 : ...        :
341 Gentoo   Biscoe              50.4          15.7               222 ...     2009
342 Gentoo   Biscoe              45.2          14.8               212 ...     2009
343 Gentoo   Biscoe              49.9          16.1               213 ...     2009

Jupyter labでの出力結果はこんな感じです。

Jupyter labの出力

penguins は RedAmber::DataFrame クラスのインスタンスで、344行のレコードがあり、8列のベクトルがあることがわかります。RedAmber では行方向のデータをレコード、列のデータをベクトル(または変数)と呼びます。

次のようにするとデータフレームの形状を調べられます。

penguins.shape

=> [344, 8]

これは、次のArrayと同じで、[行, 列]の順番になっています。

[penguins.size, penguins.n_keys]

=> [344, 8]

先ほどのデータフレームの表示の一番上の行に並んでいる species とか island は、列につけたラベルでキーと呼んでいます。 キーの一覧は下記のようにして取れます。キーは重複していないシンボルである必要があります。

penguins.keys
=>
[:species,                                                                       
 :island,                                                                        
 :bill_length_mm,                                                                
 :bill_depth_mm,                                                                 
 :flipper_length_mm,                                                             
 :body_mass_g,
 :sex,
 :year]

データフレーム表示の2行目にある<>で囲まれた文字は、ベクトルのタイプです。 データフレームでは列方向のデータは同じ長さ、かつすべて同じ型である必要があります。タイプの一覧は次のようにして取れます。

penguins.types

=> [:string, :string, :double, :double, :uint8, :uint16, :string, :uint16]

次のようにすると、Rのstr()のような詳細情報が得られます。

penguins.tdr

RedAmber::DataFrame : 344 x 8 Vectors
Vectors : 5 numeric, 3 strings                                                
# key                type   level data_preview                                
0 :species           string     3 {"Adelie"=>152, "Chinstrap"=>68, "Gentoo"=>124}
1 :island            string     3 {"Torgersen"=>52, "Biscoe"=>168, "Dream"=>124}
2 :bill_length_mm    double   165 [39.1, 39.5, 40.3, nil, 36.7, ... ], 2 nils 
3 :bill_depth_mm     double    81 [18.7, 17.4, 18.0, nil, 19.3, ... ], 2 nils 
4 :flipper_length_mm uint8     56 [181, 186, 195, nil, 193, ... ], 2 nils
5 :body_mass_g       uint16    95 [3750, 3800, 3250, nil, 3450, ... ], 2 nils
6 :sex               string     3 {"male"=>168, "female"=>165, nil=>11}
7 :year              uint16     3 {2007=>110, 2008=>114, 2009=>120}
  • 転置した見え方になっていますが、列のインデックス、キーの名前、列の型、レベル、 データのプレビューがコンパクトに表示できます。

  • レベルとはユニークなデータの数です。複数の NaN は1個と数えます。

  • レベルが5以下の場合、データは tally した形で表示します。これはRのfactor 型のような変数に対して便利な表示です。

  • RedAmber には factor 型はなく、普通は String で扱います。

数値変数に対してはつぎのようにして統計的要約を得ることができます。 この表自身も DataFrame となっています。

penguins.summary
=> 
#<RedAmber::DataFrame : 5 x 9 Vectors, 0x000000000002bfac>
  variables            count     mean      std      min      25%   median ...      max
  <dictionary>      <uint16> <double> <double> <double> <double> <double> ... <double>
0 bill_length_mm         342    43.92     5.46     32.1    39.23    44.38 ...     59.6
1 bill_depth_mm          342    17.15     1.97     13.1     15.6    17.32 ...     21.5
2 flipper_length_mm      342   200.92    14.06    172.0    190.0    197.0 ...    231.0
3 body_mass_g            342  4201.75   801.95   2700.0   3550.0   4031.5 ...   6300.0
4 year                   344  2008.03     0.82   2007.0   2007.0   2008.0 ...   2009.0

RedAmber のデータフレームは、Arrow のテーブルを使って実装されていますので、次のようにすると中身の table オブジェクトを取り出すことができます。

penguins.table
=>
(省略)

Diamondsデータの例

それでは次に別のデータセットDiamondsを読み込んで、データフレームの操作をやってみましょう。

dataset = Datasets::Diamonds.new
diamonds = DataFrame.new(dataset)
=> 
#<RedAmber::DataFrame : 53940 x 10 Vectors, 0x000000000002e414>
         carat cut       color    clarity     depth    table    price        x ...        z
      <double> <string>  <string> <string> <double> <double> <uint16> <double> ... <double>
    0     0.23 Ideal     E        SI2          61.5     55.0      326     3.95 ...     2.43
    1     0.21 Premium   E        SI1          59.8     61.0      326     3.89 ...     2.31
    2     0.23 Good      E        VS1          56.9     65.0      327     4.05 ...     2.31
    3     0.29 Premium   I        VS2          62.4     58.0      334      4.2 ...     2.63
    4     0.31 Good      J        SI2          63.3     58.0      335     4.34 ...     2.75
    :        : :         :        :               :        :        :        : ...        :
53937      0.7 Very Good D        SI1          62.8     60.0     2757     5.66 ...     3.56
53938     0.86 Premium   H        SI2          61.0     58.0     2757     6.15 ...     3.74
53939     0.75 Ideal     D        SI2          62.2     55.0     2757     5.83 ...     3.64

ここから、1カラット以上のレコードに対してカットの種類毎に価格の平均値を求める、というタスクをやってみましょう。 ちなみに詳細と統計値の要約も見ておきます。

diamonds.tdr

RedAmber::DataFrame : 53940 x 10 Vectors
Vectors : 7 numeric, 3 strings                            
#  key      type   level data_preview                     
0  :carat   double   273 [0.23, 0.21, 0.23, 0.29, 0.31, ... ]
1  :cut     string     5 {"Ideal"=>21551, "Premium"=>13791, "Good"=>4906, "Very Good"=>12082, "Fair"=>1610}                                               
2  :color   string     7 ["E", "E", "E", "I", "J", ... ]  
3  :clarity string     8 ["SI2", "SI1", "VS1", "VS2", "SI2", ... ]
4  :depth   double   184 [61.5, 59.8, 56.9, 62.4, 63.3, ... ]
5  :table   double   127 [55.0, 61.0, 65.0, 58.0, 58.0, ... ]
6  :price   uint16 11602 [326, 326, 327, 334, 335, ... ]  
7  :x       double   554 [3.95, 3.89, 4.05, 4.2, 4.34, ... ]
8  :y       double   552 [3.98, 3.84, 4.07, 4.23, 4.35, ... ]
9  :z       double   375 [2.43, 2.31, 2.31, 2.63, 2.75, ... ]
diamonds.summary
=> 
#<RedAmber::DataFrame : 7 x 9 Vectors, 0x000000000003122c>
  variables       count     mean      std      min      25%   median      75%      max
  <dictionary> <uint16> <double> <double> <double> <double> <double> <double> <double>
0 carat           53940      0.8     0.47      0.2      0.4      0.7     1.04     5.01
1 depth           53940    61.75     1.43     43.0     61.0    61.81     62.5     79.0
2 table           53940    57.46     2.23     43.0     56.0     57.0     59.0     95.0
3 price           53940   3932.8  3989.44    326.0    950.0  2299.22  5324.25  18823.0
4 x               53940     5.73     1.12      0.0     4.71     5.68     6.54    10.74
5 y               53940     5.73     1.14      0.0     4.72     5.68     6.54     58.9
6 z               53940     3.54     0.71      0.0     2.91     3.51     4.04     31.8

1カラットより大きいレコードを選び出すには次のようにします。

diamonds.slice { carat > 1 }
=> 
#<RedAmber::DataFrame : 17502 x 10 Vectors, 0x0000000000032c1c>
         carat cut       color    clarity     depth    table    price        x ...        z
      <double> <string>  <string> <string> <double> <double> <uint16> <double> ... <double>
    0     1.17 Very Good J        I1           60.2     61.0     2774     6.83 ...     4.13
    1     1.01 Premium   F        I1           61.8     60.0     2781     6.39 ...     3.94
    2     1.01 Fair      E        I1           64.5     58.0     2788     6.29 ...     4.03
    3     1.01 Premium   H        SI2          62.7     59.0     2788     6.31 ...     3.93
    4     1.05 Very Good J        SI2          63.2     56.0     2789     6.49 ...     4.09
    :        : :         :        :               :        :        :        : ...        :
17499     1.04 Very Good I        I1           61.6     61.0     2745     6.45 ...     3.98
17500     1.04 Fair      G        SI2          65.2     57.0     2745     6.25 ...     4.07
17501     1.02 Good      H        I1           64.3     63.0     2751     6.28 ...     4.02

17502行10列のデータフレームが得られました。
汎用的な書き方で書くと、

diamonds.slice(diamonds[:carat] > 1)

となりますが、ブロックを使うと上のような書き方ができます。DRY的にこの方が良いですよね。

続けて cut と price の列だけを選び取ります。

diamonds
  .slice { carat > 1 }
  .pick(:cut, :price)
=> 
#<RedAmber::DataFrame : 17502 x 2 Vectors, 0x0000000000035ad4>
      cut          price
      <string>  <uint16>
    0 Very Good     2774
    1 Premium       2781
    2 Fair          2788
    3 Premium       2788
    4 Very Good     2789
    : :                :
17499 Very Good     2745
17500 Fair          2745
17501 Good          2751

Ruby ではメソッドチェインでは右に続けて書けますが、インデントして1行ずつにした方が分かりやすいのでこのようにしています。

続けて cut の種類毎にグループ化します。

diamonds
  .slice { carat > 1 }
  .pick(:cut, :price)
  .group(:cut)
=> 
#<RedAmber::Group : 0x00000000000396ac>
  cut       group_count
  <string>     <uint16>
0 Very Good        3881
1 Premium          5729
2 Fair              651
3 Ideal            5662
4 Good             1579

#group メソッドは指定したキーの列のデータでグループ化したオブジェクト(RedAmber::Group) を返します。グループに対する操作は続けて集約のメソッドを与えることで実現できます。ここでは平均を求める #mean を適用します。

diamonds
  .slice { carat > 1 }
  .pick(:cut, :price)
  .group(:cut)
  .mean
=> 
#<RedAmber::DataFrame : 5 x 2 Vectors, 0x000000000003db44>
  cut       mean(price)
  <string>     <double>
0 Very Good     8340.55
1 Premium       8487.25
2 Fair          7177.86
3 Ideal         8674.23
4 Good           7753.6

今度は DataFrame が返ってきて、cut の値ごとの price の平均値が求められています。

最後にソートして完成させましょう。ついでに結果を変数 df に入れておきます。

df = diamonds
  .slice { carat > 1 }
  .pick(:cut, :price)
  .group(:cut)
  .mean
  .sort('-mean(price)')
=> 
#<RedAmber::DataFrame : 5 x 2 Vectors, 0x00000000000435bc>
  cut       mean(price)                 
  <string>     <double>                 
0 Ideal         8674.23                 
1 Premium       8487.25                 
2 Very Good     8340.55                 
3 Good           7753.6                 
4 Fair          7177.86

#sort の引数はキーの文字列表現を与えますが、頭に - を付けると降順にできます。

次に、得られたデータフレームのキー名を変更しましょう。キーの変更は#rename(元の名前, 新しい名前)でできます。 名前はシンボルで指定します。

df.rename(:'mean(price)', :mean_price_usd)
=> 
#<RedAmber::DataFrame : 5 x 2 Vectors, 0x0000000000051c20>                                      
  cut       mean_price_usd                                                                      
  <string>        <double>                                                                      
0 Ideal            8674.23                                                                      
1 Premium          8487.25                                                                      
2 Very Good        8340.55                                                                      
3 Good              7753.6                                                                      
4 Fair             7177.86 

次に、今ある列の隣に新しい列を作りましょう。新しい列を作るには、#assign(キーの名前) { ベクトルの値 }と書きます。ここではドル円のレートを指定して円での価格を計算します。

usdjpy = 110.0

df.rename(:'mean(price)', :mean_price_usd)
  .assign(:mean_price_jpy) { mean_price_usd * usdjpy }
=> 
#<RedAmber::DataFrame : 5 x 3 Vectors, 0x000000000005765c>
  cut       mean_price_usd mean_price_jpy
  <string>        <double>       <double>
0 Ideal            8674.23      954164.93
1 Premium          8487.25      933597.34
2 Very Good        8340.55      917460.37
3 Good              7753.6      852896.11
4 Fair             7177.86      789564.12

今より円高ですが、このデータセットができた当時のドル円レートを調べて使いました。

starwarsデータの例

次にRed Datasetからstarwarsデータセットを読み込んで操作してみましょう。
今度は、csvファイルを直接ダウンロードして読み込んでみます。

​uri = URI('https://vincentarelbundock.github.io/Rdatasets/csv/dplyr/starwars.csv')
starwars = DataFrame.load(uri)
=> 
#<RedAmber::DataFrame : 87 x 12 Vectors, 0x000000000005a26c>     
   unnamed1 name            height     mass hair_color skin_color  eye_color ... species
    <int64> <string>       <int64> <double> <string>   <string>    <string>  ... <string>
 0        1 Luke Skywalker     172     77.0 blond      fair        blue      ... Human
 1        2 C-3PO              167     75.0 NA         gold        yellow    ... Droid
 2        3 R2-D2               96     32.0 NA         white, blue red       ... Droid
 3        4 Darth Vader        202    136.0 none       white       yellow    ... Human
 4        5 Leia Organa        150     49.0 brown      light       brown     ... Human
 :        : :                    :        : :          :           :         ... :
84       85 BB8              (nil)    (nil) none       none        black     ... Droid
85       86 Captain Phasma   (nil)    (nil) unknown    unknown     unknown   ... NA
86       87 Padmé Amidala      165     45.0 brown      light       brown     ... Human

このようにして URI オブジェクトから直接ダウンロードする事ができます。データの要約を調べると、

starwars.tdr(:all) # :allを指定すると全ての変数を表示する(既定値は10)

RedAmber::DataFrame : 87 x 12 Vectors
Vectors : 4 numeric, 8 strings                                                                  
#  key         type   level data_preview                                                        
0  :unnamed1   int64     87 [1, 2, 3, 4, 5, ... ]                                               
1  :name       string    87 ["Luke Skywalker", "C-3PO", "R2-D2", "Darth Vader", "Leia Organa", ... ]                                                                                            
2  :height     int64     46 [172, 167, 96, 202, 150, ... ], 6 nils                              
3  :mass       double    39 [77.0, 75.0, 32.0, 136.0, 49.0, ... ], 28 nils                      
4  :hair_color string    13 ["blond", "NA", "NA", "none", "brown", ... ]                        
5  :skin_color string    31 ["fair", "gold", "white, blue", "white", "light", ... ]             
6  :eye_color  string    15 ["blue", "yellow", "red", "yellow", "brown", ... ]                  
7  :birth_year double    37 [19.0, 112.0, 33.0, 41.9, 19.0, ... ], 44 nils                      
8  :sex        string     5 {"male"=>60, "none"=>6, "female"=>16, "hermaphroditic"=>1, "NA"=>4} 
9  :gender     string     3 {"masculine"=>66, "feminine"=>17, "NA"=>4}                          
10 :homeworld  string    49 ["Tatooine", "Tatooine", "Naboo", "Tatooine", "Alderaan", ... ]     
11 :species    string    38 ["Human", "Droid", "Droid", "Human", "Human", ... ]

1列目は元のデータで列ラベルが空白文字となっているので、自動的に:unnamed1 という名前になっています。これを除去します。

starwars.drop(0) # drop(:unnamed1)でもOK
=> 
#<RedAmber::DataFrame : 87 x 11 Vectors, 0x000000000005f3ac>     
   name            height     mass hair_color skin_color  eye_color birth_year ... species
   <string>       <int64> <double> <string>   <string>    <string>    <double> ... <string>
 0 Luke Skywalker     172     77.0 blond      fair        blue            19.0 ... Human
 1 C-3PO              167     75.0 NA         gold        yellow         112.0 ... Droid
 2 R2-D2               96     32.0 NA         white, blue red             33.0 ... Droid
 3 Darth Vader        202    136.0 none       white       yellow          41.9 ... Human
 4 Leia Organa        150     49.0 brown      light       brown           19.0 ... Human
 : :                    :        : :          :           :                  : ... :
84 BB8              (nil)    (nil) none       none        black          (nil) ... Droid
85 Captain Phasma   (nil)    (nil) unknown    unknown     unknown        (nil) ... NA
86 Padmé Amidala      165     45.0 brown      light       brown           46.0 ... Human

:species列にはいくつか"NA"が含まれています。これを含む列を取り除きます。

starwars
  .drop(0)
  .remove { species == 'NA' } # slice { species == 'NA' }の逆です
=> 
#<RedAmber::DataFrame : 83 x 11 Vectors, 0x0000000000075c10>                              
   name            height     mass hair_color skin_color  eye_color birth_year ... species
   <string>       <int64> <double> <string>   <string>    <string>    <double> ... <string>
 0 Luke Skywalker     172     77.0 blond      fair        blue            19.0 ... Human  
 1 C-3PO              167     75.0 NA         gold        yellow         112.0 ... Droid  
 2 R2-D2               96     32.0 NA         white, blue red             33.0 ... Droid  
 3 Darth Vader        202    136.0 none       white       yellow          41.9 ... Human  
 4 Leia Organa        150     49.0 brown      light       brown           19.0 ... Human  
 : :                    :        : :          :           :                  : ... :      
80 Poe Dameron      (nil)    (nil) brown      light       brown          (nil) ... Human  
81 BB8              (nil)    (nil) none       none        black          (nil) ... Droid  
82 Padmé Amidala      165     45.0 brown      light       brown           46.0 ... Human

:species でグループ化して、データ数, :height, :massの平均値を求めます。

starwars
  .drop(0)
  .remove { species == 'NA' } 
  .group(:species) { [count(:species), mean(:height, :mass)] }
=> 
#<RedAmber::DataFrame : 37 x 4 Vectors, 0x000000000007efcc>
   species    count mean(height) mean(mass)
   <string> <int64>     <double>   <double>
 0 Human         35       176.65      82.78
 1 Droid          6        131.2      69.75
 2 Wookiee        2        231.0      124.0
 3 Rodian         1        173.0       74.0
 4 Hutt           1        175.0     1358.0
 : :              :            :          :
34 Kaleesh        1        216.0      159.0
35 Pau'an         1        206.0       80.0
36 Kel Dor        1        188.0       80.0

countが1より大きいレコードを選んでみました。

starwars
  .drop(0)
  .remove { species == 'NA' } 
  .group(:species) { [count(:species), mean(:height, :mass)] }
  .slice { count > 1 }
=> 
#<RedAmber::DataFrame : 8 x 4 Vectors, 0x000000000008b808>
  species    count mean(height) mean(mass)
  <string> <int64>     <double>   <double>
0 Human         35       176.65      82.78
1 Droid          6        131.2      69.75
2 Wookiee        2        231.0      124.0
3 Gungan         3       208.67       74.0
4 Zabrak         2        173.0       80.0
5 Twi'lek        2        179.0       55.0
6 Mirialan       2        168.0       53.1
7 Kaminoan       2        221.0       88.0

この例はRの例を参考にしました。


ぜひ使ってみてください

Rubyらしい書き方でデータフレームの操作ができる事がわかっていただけたでしょうか?RedAmberはまだ開発途上ですが、主要な操作はできるようになっていると思いますので使ってみていただけると嬉しいです。

APIのドキュメントは未完成ですが、

があるので参考にしてください。

私はずっとRubyを使う側でしたが、初めてRubyで作る側に回ってみて新しい発見がたくさんありました。Apache Arrowを中心としたデータ解析用ツールの世界はアイデア次第でいろいろな事ができる宝の山だと感じます。興味がある方はぜひRed Data Toolsにいらしてください!(コードを書くだけが開発ではないですし、現時点での能力の不足は問題ではないそうです。今年の春頃まで私もOSS初心者でした!)

質問、コメント等ございましたら、RedAmberのディスカッション(日本語でもOKです)または Red Data Toolsのgitter を使っていただければと思います。


この記事が気に入ったらサポートをしてみませんか?