Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Ruby: 配列要素の平均値を取るときのコツ(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

Ruby: 配列要素の平均値を取るときのコツ(翻訳)

Rubyは、要素がintegerの配列の平均値を生成するネイティブのメソッドを提供していません。Mathライブラリは、より複雑な計算メソッドに注力しており、Array組み込みの#averageメソッドや#meanメソッドはありません。

つまり、これらのメソッドを独自に作成する余地がある分、自分の足を撃ち抜く可能性も生じます。

以下のようにすること

要素がintegerの配列の平均値を算出するには、以下のようにArray#sumを用いる。

a = [1, 2, 3, 4, 5, 6, 7, 8]

a.sum(0.0) / a.size
#=> 4.5

そうすべき理由

Array#sumは、injectよりもずっとずっと高速です。

Array#sumが導入されたのはRuby 2.4からでした。Array#sum以外のやり方(訳注: injectを使う方法)がネット上に山ほど落ちている理由は、これです。

benchmark-ips gemで実装ごとのパフォーマンスの違いを比較できます。

require "benchmark/ips"

# 要素が1,000個の配列を生成する
a = Array.new(1000) { |_| rand(1000) }

Benchmark.ips do |x|
  x.report("sum(0.0) / size") do
    a.sum(0.0) / a.size
  end
  x.report('inject(0.0) / size') do
    a.inject(0.0) { |result, i| result + i } / a.size
  end
  x.compare!
end

私のノートPCでは、ネイティブの#sumを用いると50倍も速くなりました。このメソッドは、まさにこうしたパフォーマンス向上のためにRubyに組み込まれたのです。

Calculating -------------------------------------
   sum(0.0) / size  680.425k (± 6.5%) i/s -  3.432M in 5.06s
inject(0.0) / size   13.513k (± 5.0%) i/s - 67.586k in 5.01s

Comparison:
   sum(0.0) / size: 680425.2 i/s
inject(0.0) / size:  13512.7 i/s - 50.35x  slower

訳注: 訳者のMacbook Pro (2019)では69倍の違いが生じました。

#sumしてから#sizeするのと同様の手法は他にもいろいろありますが、パフォーマンス上の寄与が最も大きいのは、最初にネイティブの#sumメソッドを用いることです。

さらに多くの実装でベンチマークしてみましょう。

require "benchmark/ips"

# 要素が1,000個の配列を生成する
a = Array.new(1000) { |_| rand(1000) }

Benchmark.ips do |x|
  x.report("sum(0.0) / size") do
    a.sum(0.0) / a.size
  end
  x.report("sum.to_f / size") do
    a.sum.to_f / a.size
  end
  x.report("sum / size.to_f") do
    a.sum / a.size.to_f
  end
  x.report("sum.fdiv(size)") do
    a.sum.fdiv(a.size)
  end
  x.report('inject(0.0, :+) / size') do
    a.inject(0.0, :+) / a.size
  end
  x.report('inject(0.0) / size') do
    a.inject(0.0) { |result, i| result + i } / a.size
  end
  x.report('inject(0).to_f / size') do
    a.inject(0) { |result, i| result + i }.to_f / a.size
  end
  x.report('inject(0) / size.to_f') do
    a.inject(0) { |result, i| result + i } / a.size.to_f
  end
  x.report('inject(0).fdiv(size)') do
    a.inject(0) { |result, i| result + i }.fdiv(a.size)
  end
  x.compare!
end

結果は以下のとおりです。

Comparison:
       sum(0.0) / size: 668222.4 i/s
       sum / size.to_f: 660291.4 i/s - same-ish
       sum.to_f / size: 655929.1 i/s - same-ish
        sum.fdiv(size): 621960.0 i/s - same-ish
inject(0.0, :+) / size:  30823.6 i/s - 21.68x slower
 inject(0) / size.to_f:  18740.7 i/s - 35.66x slower
 inject(0).to_f / size:  18320.1 i/s - 36.47x slower
  inject(0).fdiv(size):  18082.6 i/s - 36.95x slower
    inject(0.0) / size:  15264.0 i/s - 43.78x slower

他の方法はあるか

RailsのActive Recordには#averageメソッドがあります。このメソッドは、データベース上の数値カラムを直接SQLで計算します。

求めているユースケースがそれであれば、ぜひこの#averageを使うべきです。Active Recordモデルをインスタンス化してからRubyの配列をイテレートすると、ほぼ確実に遅くなります。

関連記事

Ruby 2.4〜の`#clamp`で最大・最小値をまとめて制約(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。