「それNArrayでできるよ」をもっと便利にした - 札幌市中央区Ruby会議01
もう先週の話になってしまいましたが、Tricknotesさん主催の札幌市中央区Ruby会議01に参加してきました。 コンパクトな規模ながらも「非常に良いRubyistの活動が観測」(byしまださん)された、有意義かつ楽しい会議でした。
どの発表も聴き応えのあるすばらしいものでしたが、中でもtmaedaさんの「それNArrayでできるよ」は数値計算ライブラリのNArrayをbetter Excelとして使ってしまうコペルニクス的発想で衝撃的でした。
この発表のなかで、NArrayの軸をHuman-Readableな形式で扱えるラッパークラスを作ると便利だよ、というお話がありましたが、
これをもっと便利にするNArrayCube
クラスを試験的に実装してみたので紹介します。
使い方
# 店、世代、性別の3軸からなる売上表を作成 cube = NArrayCube.new(:int, { title: :shop, labels: ['A', 'B', 'C'] }, { title: :generation, labels: ['teens', 'twenties', 'thirties', 'fourties'] }, { title: :gender, labels: ['men', 'women'] } ) # 店A、20代、男性の売上を入力 cube.set({ shop: 'A', generation: 'teens', gender: 'men' }, 100) # 店A、10代の売上を入力 cube.set({ shop: 'A', generation: 'teens' }, [90, 110]) # 店Bの売上を一気に入力(4世代 × 2性別のArray) cube.set({ shop: 'B'}, [ [50, 80, 60, 75], [70, 40, 35, 50] ]) # 店Cの売上を一気に入力:第2引数を一次元のArrayで与えると勝手に4x2に変換してくれる cube.set({ shop: 'C'}, [70, 80, 55, 60, 30, 45, 70, 60]) # 10代の売上を取得 cube.get({ generation: 'teens' }) #=> # #<NArrayCube: ... # [ [ 90, 50, 70 ], # [ 110, 70, 30 ] ]> # 10代の売上の合計 cube.get({ generation: 'teens' }).sum #=> 420 # 10代の売上を店ごとに集計 => 男性+女性の値を計算すればよい cube.get({ generation: 'teens' }).sum(:gender) #=> # NArray.int(3): # [ 200, 120, 100 ]
ツボ
NArrayCube#set
で複数のセルに一気に値を流し込めるNArray#sum
をNArrayCube#sum
でラップしたことにより、軸の指定をHuman-Readableに行える
TODO
- spec書く
- document書く
NArray#sum
だけじゃなくprod
/max
/min
とかその他のメソッドにも対応したい =>method_missing
とか使えるかな?- 「10代の売上を店ごとに集計」は
cube.get({ generation: 'teens' }).sum_by(:shop)
みたいに書けたほうが直感的 - getで軸ラベルを複数指定できたほうがいい
cube.get({ shop: ['A', 'B'] })
- tmaedaさんがいいよって言ってくれたらGem化して公開
コード
require 'narray' class NArrayCube NARRAY_TYPES = %i(byte sint int sfloat float scomplex complex object) class NArrayAxis def initialize(id, title, labels) @id = id @title = title.to_s @labels = labels.map(&:to_s) end def value_index(value) @labels.index(value.to_s) end attr_reader :id, :title, :labels end def initialize(type, *axes) if NARRAY_TYPES.include?(type) @type = type else raise ArgumentError end @axes = axes.map.with_index do |a, i| NArrayAxis.new(i, a[:title], a[:labels]) end @narray = NArray.method(type).call(*@axes.map { |a| a.labels.length }) end def get(conditions) conditions = build_condition(conditions) values = @narray[*conditions] axes = conditions.map.with_index do |c, id| if c.class == Array a = @axes[id] { title: a.title, labels: a.labels.select.with_index { |v, i| c.include? i } } else nil end end NArrayCube.new(@type, *axes.compact).set({}, values) end def set(conditions, values) conditions = build_condition(conditions) unless [Array, NArray].include?(values.class) @narray[*conditions] = values else partial = @narray[*conditions] values = NArray.to_na(values) if partial.shape == values.shape @narray[*conditions] = values elsif partial.total == values.total && values.shape.length == 1 @narray[*conditions] = values.reshape(*partial.shape) else raise ArgumentError end end self end def sum(*titles) if titles.length > 0 @narray.sum(*titles.map { |t| axis(t).id }) else @narray.sum end end private def build_condition(conditions) cond = @axes.map { |a| (0..(a.labels.length - 1)).to_a } conditions.each do |title, label| a = axis(title) cond[a.id] = a.value_index(label) end cond end def axis(title) @axes.select { |a| a.title == title.to_s }.first end end