hp12c
24 January 2013

Ruby標準添付ライブラリcsvのCSV.tableメソッドが最強な件について

─ 問題1 ─

data.csvファイルには、5人のプレイヤー(Alice, Bob, Jimmy, Kent, Ross)が二種類のゲーム(gameA, gameB)をプレイした結果が次のような形で格納されている。各ゲームの平均点を求めよ。

data.csv

player,gameA,gameB
Alice,84.0,79.5
Bob,20.0,56.5
Jimmy,80.0,31.0
Kent,90.5,15.5
Ross,68.0,33.0

─ 僕の通った道 ─

1. File.readしてtransposeする

data = File.read('data.csv')
headers, *scores = data.lines.map { |line| line.chomp.split(',') }
scores # => [["Alice", "84.0", "79.5"], ["Bob", "20.0", "56.5"], ["Jimmy", "80.0", "31.0"], ["Kent", "90.5", "15.5"], ["Ross", "68.0", "33.0"]]
_, *ab = scores.transpose # => [["Alice", "Bob", "Jimmy", "Kent", "Ross"], ["84.0", "20.0", "80.0", "90.5", "68.0"], ["79.5", "56.5", "31.0", "15.5", "33.0"]]
avgA, avgB = ab.map { |e| e.map(&:to_f).inject(:+) / e.size } # => [68.5, 43.1]

まあ、悪くはないですけど、String#linesして#splitしてってのはどうですかね。空白あっても困るし。正規表現ですか?Array#transposeはなかなかいいアイディアですけどね。

2. csvライブラリでreadする

CSVファイルって言ってるんですから、素直に標準添付ライブラリcsvを使えばいいんですよ。

require "csv"
headers, *scores = CSV.read('data.csv')
headers # => ["player", "gameA", "gameB"]
scores # => [["Alice", "84.0", "79.5"], ["Bob", "20.0", "56.5"], ["Jimmy", "80.0", "31.0"], ["Kent", "90.5", "15.5"], ["Ross", "68.0", "33.0"]]
_, *ab = scores.transpose # => [["Alice", "Bob", "Jimmy", "Kent", "Ross"], ["84.0", "20.0", "80.0", "90.5", "68.0"], ["79.5", "56.5", "31.0", "15.5", "33.0"]]
avgA, avgB = ab.map { |e| e.map(&:to_f).inject(:+) / e.size } # => [68.5, 43.1]

ワンステップ少なくなりましたね。それにしても、あなた、多重代入好きですねぇ。

3. csvライブラリで.tableする

でもね、このライブラリにはすごい必殺技があるんですよ。ヒヒィ。

table = CSV.table('data.csv')
table.headers # => [:player, :gamea, :gameb]
avgA, avgB = [:gamea, :gameb].map { |e| table[e].inject(:+) / table.size } # => [68.5, 43.1]

えっ?何が起きたの?

transposeは?to_fは?どこに逝ったの?誰がやったの?

CSV.tableって何?

先のCSV.readは二次元配列を返しますよ。

csv = CSV.read('data.csv') # => [["player", "gameA", "gameB"], ["Alice", "84.0", "79.5"], ["Bob", "20.0", "56.5"], ["Jimmy", "80.0", "31.0"], ["Kent", "90.5", "15.5"], ["Ross", "68.0", "33.0"]]
csv.class # => Array
csv.first # => ["player", "gameA", "gameB"]
csv[1] # => ["Alice", "84.0", "79.5"]
csv[2] # => ["Bob", "20.0", "56.5"]

でも、CSV.tableはCSV::Tableというテーブル向けクラスのインスタンスを返すんですよ。ヘッダー要素には#headersでアクセスできますし。

table = CSV.table('data.csv') # => #<CSV::Table mode:col_or_row row_count:6>
table.class # => CSV::Table
table.headers # => [:player, :gamea, :gameb]

で、テーブルの各レコードは、CSV::Rowクラスのインスタンスでラップされとるんですな。

table.first # => #<CSV::Row player:"Alice" gamea:84.0 gameb:79.5>
table[1] # => #<CSV::Row player:"Bob" gamea:20.0 gameb:56.5>
table[2] # => #<CSV::Row player:"Jimmy" gamea:80.0 gameb:31.0>

CSV::Tableのインスタンスは、テーブルのアクセス方向を指定するモード(:row, :column, :col_or_rowの何れか)を持ってて、デフォルトでこれは:col_or_row(ミックスモード)にセットされますですよ。

それでTable#[ ]メソッドに渡される引数に応じて、そのアクセス方向がよしなに判断されるってわけです。

table[1] # => #<CSV::Row player:"Bob" gamea:20.0 gameb:56.5>
table[2] # => #<CSV::Row player:"Jimmy" gamea:80.0 gameb:31.0>
table[:player] # => ["Alice", "Bob", "Jimmy", "Kent", "Ross"]
table[:gamea] # => [84.0, 20.0, 80.0, 90.5, 68.0]
table[:gameb] # => [79.5, 56.5, 31.0, 15.5, 33.0]

ほら、特定フィールドのヘッダーを渡せば、そのフィールドの配列が返るだとか!

しかも、ヘッダー値は文字列から自動的にシンボルに変換されているの、わかるでしょう?数値の値も自動でFloatになっているのわかるでしょう?

CSV.table、最強!って、誰が言っても怒りませんよ。もう。

CSVインスタンスの生成オプション

CSVのインスタンスを生成するときに複数のオプションを渡すことができるんです。実は、CSV.tableはそのオプションの幾つかを自動設定するものなのでした。

CSV.readにオプションを渡して、CSV.tableに対抗しますか。

csv = CSV.read('data.csv', headers:true, converters: :numeric, header_converters: :symbol) # => #<CSV::Table mode:col_or_row row_count:6>
csv.class # => CSV::Table
csv.first # => #<CSV::Row player:"Alice" gamea:84.0 gameb:79.5>
csv[1] # => #<CSV::Row player:"Bob" gamea:20.0 gameb:56.5>
csv[2] # => #<CSV::Row player:"Jimmy" gamea:80.0 gameb:31.0>
csv[:player] # => ["Alice", "Bob", "Jimmy", "Kent", "Ross"]
csv[:gamea] # => [84.0, 20.0, 80.0, 90.5, 68.0]
csv[:gameb] # => [79.5, 56.5, 31.0, 15.5, 33.0]

なあ〜るほど、なあ〜るほど。

─ 問題2 ─

data2.csvファイルには、5人のプレイヤー(Alice, Bob, Jimmy, Kent, Ross)が二種類のゲーム(gameA, gameB)をそれぞれ3回ずつプレイした結果が次のような形で格納されている。各プレイヤー毎の各ゲームの平均点を求めよ。

player,gameA,gameB
Alice,84.0,79.5
Bob,20.0,56.5
Jimmy,80.0,31.0
Kent,90.5,15.5
Ross,68.0,33.0
Alice,24.0,15.5
Bob,60.0,16.5
Jimmy,85.0,42.0
Kent,55.5,15.5
Ross,22.0,33.5
Alice,64.5,39.5
Bob,25.0,50.5
Jimmy,60.0,61.0
Kent,70.5,25.0
Ross,48.0,36.5

次のような出力で。

player gamea gameb
Alice 57.50 44.83
Bob 35.00 41.17
Jimmy 75.00 44.67
Kent 72.17 18.67
Ross 46.00 34.33

なんか、昨日の問題に近づいて来ましたけど。

Rubyで点数を集計するとき、あなたはどうしてますか?

─ 僕の通った道その2 ─

Enumerable#group_byを使う

table = CSV.table('data2.csv') # => #<CSV::Table mode:col_or_row row_count:16>
scores_by_player = table.group_by(&:first) # => {[:player, "Alice"]=>[#<CSV::Row player:"Alice" gamea:84.0 gameb:79.5>, #<CSV::Row player:"Alice" gamea:24.0 gameb:15.5>, #<CSV::Row player:"Alice" gamea:64.5 gameb:39.5>], [:player, "Bob"]=>[#<CSV::Row player:"Bob" gamea:20.0 gameb:56.5>, #<CSV::Row player:"Bob" gamea:60.0 gameb:16.5>, #<CSV::Row player:"Bob" gamea:25.0 gameb:50.5>], [:player, "Jimmy"]=>[#<CSV::Row player:"Jimmy" gamea:80.0 gameb:31.0>, #<CSV::Row player:"Jimmy" gamea:85.0 gameb:42.0>, #<CSV::Row player:"Jimmy" gamea:60.0 gameb:61.0>], [:player, "Kent"]=>[#<CSV::Row player:"Kent" gamea:90.5 gameb:15.5>, #<CSV::Row player:"Kent" gamea:55.5 gameb:15.5>, #<CSV::Row player:"Kent" gamea:70.5 gameb:25.0>], [:player, "Ross"]=>[#<CSV::Row player:"Ross" gamea:68.0 gameb:33.0>, #<CSV::Row player:"Ross" gamea:22.0 gameb:33.5>, #<CSV::Row player:"Ross" gamea:48.0 gameb:36.5>]}
stat = scores_by_player.map do |(_, player), rows|
 avgA = rows.map { |r| r[:gamea] }.inject(:+) / rows.size
 avgB = rows.map { |r| r[:gameb] }.inject(:+) / rows.size
 [player, avgA, avgB]
end
stat # => [["Alice", 57.5, 44.833333333333336], ["Bob", 35.0, 41.166666666666664], ["Jimmy", 75.0, 44.666666666666664], ["Kent", 72.16666666666667, 18.666666666666668], ["Ross", 46.0, 34.333333333333336]]
puts "%s\t%s\t%s" % table.headers
puts stat.map { |d| "%s\t%.02f\t%.02f" % d }
# >> player gamea gameb
# >> Alice 57.50 44.83
# >> Bob 35.00 41.17
# >> Jimmy 75.00 44.67
# >> Kent 72.17 18.67
# >> Ross 46.00 34.33

概ねいい感じですか。でも、CSV::Tableのミックスモードのパワーが使えなくなっちゃった。mapして各rowから対象データを一つづつ取ってこなくちゃならないなんて。group_byがいかんですよ、これは。

group_byをハックする

なら、group_byをオーバーライドってことになりますな。

class CSV::Table
 def group_by(&blk)
 Hash[ super.map { |k, v| [k, CSV::Table.new(v)] } ]
 end
end

さて。

table = CSV.table('data2.csv') # => #<CSV::Table mode:col_or_row row_count:16>
scores_by_player = table.group_by(&:first) # => {[:player, "Alice"]=>#<CSV::Table mode:col_or_row row_count:4>, [:player, "Bob"]=>#<CSV::Table mode:col_or_row row_count:4>, [:player, "Jimmy"]=>#<CSV::Table mode:col_or_row row_count:4>, [:player, "Kent"]=>#<CSV::Table mode:col_or_row row_count:4>, [:player, "Ross"]=>#<CSV::Table mode:col_or_row row_count:4>}
stat = scores_by_player.map do |(_, player), t|
 avgA, avgB = [:gamea, :gameb].map { |e| t[e].inject(:+) / t.size }
 [player, avgA, avgB]
end
stat # => [["Alice", 57.5, 44.833333333333336], ["Bob", 35.0, 41.166666666666664], ["Jimmy", 75.0, 44.666666666666664], ["Kent", 72.16666666666667, 18.666666666666668], ["Ross", 46.0, 34.333333333333336]]
puts "%s\t%s\t%s" % table.headers
puts stat.map { |d| "%s\t%.02f\t%.02f" % d }
# >> player gamea gameb
# >> Alice 57.50 44.83
# >> Bob 35.00 41.17
# >> Jimmy 75.00 44.67
# >> Kent 72.17 18.67
# >> Ross 46.00 34.33

いいんじゃないですかね!

CSV.new

ちなみに昨日の問題のような、スペース区切りの文字列データはどうですか?CSV.newしますか。col_sepオプション渡して。

昨日の問題です。

5人のプレイヤー(Alice, Bob, Jimmy, Kent, Ross)が二種類のゲーム(gameA, gameB)をそれぞれ3回ずつプレイした結果のデータdataがある。

data =<<EOS
player gameA gameB
Bob 20 56
Ross 68 33
Bob 78 55
Kent 90 15
Alice 84 79
Ross 10 15
Jimmy 80 31
Bob 12 36
Kent 88 43
Kent 12 33
Alice 90 32
Ross 67 77
Alice 56 92
Jimmy 33 88
Jimmy 11 87
EOS

結果を集計し以下の標準出力を得よ(totalで降順)。

% ruby game_score.rb
player gameA gameB total
Alice 230 203 433
Jimmy 124 206 330
Kent 190 91 281
Ross 145 125 270
Bob 110 147 257

僕の答え

require "csv"
class CSV
 def group_by(&blk)
 Hash[ super.map { |k, v| [k, CSV::Table.new(v)] } ]
 end
end
csv = CSV.new(data, col_sep:' ', headers:true, converters: :numeric, header_converters: :symbol) # => <#CSV io_type:StringIO encoding:UTF-8 lineno:0 col_sep:" " row_sep:"\n" quote_char:"\"" headers:true>
scores_by_player = csv.group_by(&:first) # => {[:player, "Ross"]=>#<CSV::Table mode:col_or_row row_count:4>, [:player, "Bob"]=>#<CSV::Table mode:col_or_row row_count:3>, [:player, "Kent"]=>#<CSV::Table mode:col_or_row row_count:4>, [:player, "Alice"]=>#<CSV::Table mode:col_or_row row_count:4>, [:player, "Jimmy"]=>#<CSV::Table mode:col_or_row row_count:4>}
stat = scores_by_player.map do |(_, player), t|
 ab = [:gamea, :gameb].map { |e| t[e].inject(:+) }
 [player, *ab, ab.inject(:+)]
end
stat # => [["Ross", 145, 125, 270], ["Bob", 90, 91, 181], ["Kent", 190, 91, 281], ["Alice", 230, 203, 433], ["Jimmy", 124, 206, 330]]
puts "%s\t%s\t%s\ttotal" % csv.headers
puts stat.sort_by{ |s| -s.last }.map { |line| "%s\t%d\t%d\t%d" % line }
# >> player	gamea	gameb	total
# >> Alice	230	203	433
# >> Jimmy	124	206	330
# >> Kent	190	91	281
# >> Ross	145	125	270
# >> Bob	90	91	181

ほほぅ。



本日知ったCSV.tableの感動を冷めやらぬ前に皆様にお届けしましたm(__)m


library csv


電子書籍でRuby始めてみませんか?

M’ELBORNE BOOKS


(追記:2013年01月25日)コード中のtypoを直しました。



Please enable JavaScript to view the comments powered by Disqus. blog comments powered by Disqus
ruby_pack8

100円〜で好評発売中!
M'ELBORNE BOOKS


AltStyle によって変換されたページ (->オリジナル) /