─ 問題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を直しました。



blog comments powered by Disqus
ruby_pack8

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