Lambdaカクテル

京都在住Webエンジニアの日記です

Invite link for Scalaわいわいランド

じゃあ何すか、COBOL以外では4.8 - 4.7 - 0.1できないってことっすか / ScalaとSpireで安心安全な計算ライフを実現しよう

先日こういうツイートが流れてきた。

Q:なぜ金融系では未だにCOBOLが使われるんですか?

A:お手元にExcelがありましたら任意のセルに「=4.8-4.7-0.1」って入れてみてください。

普段我々がゴリゴリ馬車馬のように使っているソフトウェアでよく利用されている浮動小数点型、すなわちfloatdoubleなどは特定の算術に弱いことが知られている。というかもうこの手の話題はあまりに拡散されてしまったので、なぜかネット民はみんな知っている基礎教養、三毛別羆事件とかデーモンコアみたいな感じになっている。

ちなみにこれはCOBOLかそうではないか、という軸が問題になっているのではなく、浮動小数点型を利用するか、それともBigDecimalのような十進演算のために用意された型を利用するか、という軸の問題であって、しかもそれも正確な軸の取りかたではない。

というのも、BigDecimalでカバーされない問題があるのだ。例えば、BigDecimal型を利用しても(1 / 3) * 3の結果はすぐ壊れる。

scala> (BigDecimal(1) / BigDecimal(3)) * BigDecimal(3)
val res0: BigDecimal = 0.9999999999999999999999999999999999

ふつう、この演算の結果は1を期待するはずだ。

ゆえにこれは、正確にはどの数値表現を選ぶかという話題である。floatBigDecimalかという問題軸ではなく、ほげ言語かCOBOLか、という問題軸でもない。

Spire -- 正確な数値計算のためのライブラリ

じゃあ何すか、COBOL以外では4.8 - 4.7 - 0.1(1 / 3) * 3もできないってことっすか・・・!

とそんな我々のためにありがたいライブラリがあるのを昨日行なわれたScalaMatsuriで教えてもらった。その名もSpire(スパイア)。

typelevel.org

speakerdeck.com

Spireはプログラミング言語Scalaのためのライブラリで、汎用的で、高速で、正確な数値計算を提供してくれる。これを使えば先程の数式は何の問題もなく解を出せる:

println(r"4.8" - r"4.7" - r"0.1") // => 0

そんなSpireの便利な使い方について紹介する。

Scalaの利用

初めてScalaを聞いた人のためにDockerを使った方法を紹介する。Docker経由でScalaの実行コマンドであるScala CLIを利用しよう。

以下のエイリアスを設定することでDockerだけで開発できる:

alias scala-cli="docker run --rm -it \
  -v /etc/passwd:/etc/passwd:ro \
  -v /etc/group:/etc/group:ro \
  -v $HOME:/home/`id -u -n` \
  -v .:/app \
  -w /app \
  -u `id -u`:`id -g` \
  virtuslab/scala-cli --server=false"

このエイリアスはホームディレクトリをマウントし、ホストにライブラリのキャッシュを保存してくれる。がDockerなので若干モッサリした挙動になるのは許してほしい。

Dockerを利用せず、Scala CLIをホストにインストールするには、以下の手順に従う:

# Linux
curl -sSLf https://scala-cli.virtuslab.org/get | sh

# macOS
brew install Virtuslab/scala-cli/scala-cli

Scala Scriptのファイル作成

まずはファイルを作成しよう。spire.scala.scを適当な場所に保存する:

//> using scala 3.4.2

.scala.scはScalaをスクリプトとして実行するための拡張子だ。Scala Scriptについては以下の記事を参考にしてほしい:

blog.3qe.us

ライブラリのインストール

さらに、ライブラリの依存性を追加しよう。次の行を追記する:

//> using dep "org.typelevel::spire::0.18.0"

Spireをインポートする

以下の行を書くことでSpireの機能を使えるようになる:

import spire._
import spire.math._
import spire.implicits._

次はいよいよ実践だ。

Rational

Spireは有理数を表現するためのRational型が用意されている。数値からRationalを作るにはRational(10)のように1つだけ引数を渡す。分子と分母を利用したい場合はRational(1, 3)のように分子・分母の順で2つ引数を渡せばいい。

val r1 = Rational(42)
val r2 = Rational(1, 3)

Rational型は通常の数値同様に演算ができる。

println(r1 * r2)

この時点で以下のようなスクリプトになっているはずだ:

//> using scala 3.4.2
//> using dep "org.typelevel::spire::0.18.0"

import spire._
import spire.math._
import spire.implicits._

val r1 = Rational(42)
val r2 = Rational(1, 3)

println(r1 * r2)

スクリプトを実行してみよう。scala-cli run ファイルで実行できる。

ちなみに初回はライブラリやコンパイラをネットから拾ってくるので時間がかかるが、二度目からはずっと高速に起動する。

% scala-cli run spire.scala.sc
14

42 * (1 / 3)が計算された。

Rationalの便利なところは、有理数を有理数のまま保持してくれるところだ:

println(Rational(7, 9))
7/9

この便利な性質によって、正確に計算ができる:

println(((7.0 / 9.0) * 1000000000) * (9.0 / 7.0) / 1000000000) // => 1.0000000000000002

println(((Rational(7) / Rational(9)) * Rational(1000000000)) * (Rational(9) / Rational(7)) / Rational(1000000000))
// => 1

r記法

ちなみにRationalを書くための便利な記法としてr""が用意されている:

println(r"7/9" * r"1000000000" * r"9/7" / r"1000000000") // => 1

r記法を使うと自動的に最初からRationalとして読み込まれるため、最初の計算も問題なく行える:

println(r"4.8" - r"4.7" - r"0.1") // => 0

RationalをBigDecimalに変換する

最終的にユーザに表示するなどの理由でRationalを十進に戻すにはtoBigDecimalメソッドを呼ぶとよい。このメソッドは、欲しい精度(ケタ数)と丸めモードを教えることでBigDecimalを返してくれる:

println((r"1" / r"3").toBigDecimal(4, java.math.RoundingMode.HALF_EVEN)) // => 0.3333

これはとても便利だ。

Polynomial

Spireの特筆すべき面白い機能としてPolynomial型、つまり多項式を表現するための型がある。この機能を使うと、動的に式を組み立てて正確な計算ができる。

そんな機能をいつ使うのかと思うかもしれないが、携帯電話の料金プランについて考えてみよう。年齢による割引、月払いか年払いかによる割引、クーポンで一定料金が割引される仕組み・・・。契約プランは動的な計算に満ちている。

例えば以下のようなプランを考えてみよう:

  • 大人と子供の別がある。子供は月間料金が10%引きになる
  • 月払いか年間払いの別がある。年間で払うと年間料金から2000円が割引となり、実際はこれを12で割った額を月額料金から割引する
  • クーポンを持っている場合は月間料金が1%引きになる
  • 割引順序は年間割、年齢割、クーポンの順で行わなければならない

オエッ!結構難しいしこれらを矛盾無く組み立てるのは結構難しい。適当にやるとすぐ終わりそうだが、これはお金の問題だ。絶対にミスできない。

こういうとき、Polynomialがあると非常に便利だ。実際にやってみよう。

まずは大人/子供、月間/年間の区別を付けるためにenumを用意しよう:

enum Person:
  case Adult
  case Child

enum Duration:
  case Annual
  case Monthly

Polynomialを作るにはpoly記法を使う。この結果、Polynomial[Rational]型の値ができる。まず年齢による割引を定義してみよう:

val ageDiscount = client match
  case Person.Adult => poly"1x"
  case Person.Child => poly"0.9x"

非常に直感的に記述できた。この調子で他の割引も定義してみよう:

val durationDiscount = planDuration match
  case Duration.Annual =>
    val constantDiscount = r"2000/12" // 注意: スペースを入れない
    poly"x - $constantDiscount"
  case Duration.Monthly => poly"x"

val couponDiscount = if hasCoupon then poly"0.99x" else poly"x"

Polynomialにはcomposeメソッドがあるので、複数の割引を決まった順序で合成できる:

couponDiscount.compose(ageDiscount).compose(durationDiscount)

x.compose(y)yしてからxなのでこの順序になる。

これで完成だ。calculateComplicatedMonthlyPricingDiscountという名前にしよう:

def calculateComplicatedMonthlyPricingDiscount(
    client: Person,
    planDuration: Duration,
    hasCoupon: Boolean,
): spire.math.Polynomial[spire.math.Rational] = {
  // We are mobile network carrier company.
  // We have many complecated payment pricing and discount.
  import spire._
  import spire.math._
  import spire.implicits._

  val ageDiscount = client match
    case Person.Adult => poly"1x"
    case Person.Child => poly"0.9x"

  val durationDiscount = planDuration match
    case Duration.Annual =>
      val constantDiscount = r"2000/12" // caveat: no space between 2000 and /.
      poly"x - $constantDiscount" // Discount 2000 yen from annual fee.
    case Duration.Monthly => poly"x"

  val couponDiscount = if hasCoupon then poly"0.99x" else poly"x"

  // First apply duration discount, next age discount, then coupon discount.
  couponDiscount.compose(ageDiscount).compose(durationDiscount)
}

この計算がうまく機能するか確認してみよう。「子供,年間プラン、クーポンあり」の場合を計算してもらう。Polynomialに実際に値を代入するにはメソッドのようにf(2000)のように値を渡せばよい。今回はベースとなる月額が2000円だとしよう。

import spire.syntax.literals.r
val basePrice = r"2000"

val discounted = calculateComplicatedMonthlyPricingDiscount(
  Person.Child,
  Duration.Annual,
  hasCoupon = true,
)(basePrice)

println(
  discounted.toBigDecimal(4, java.math.RoundingMode.HALF_EVEN),
) // => (2000 - (2000 / 12)) * 0.9 * 0.99
// => 1633.5000
println("without Spire:")
println(
  (2000 - (2000 / 12)) * 0.9 * 0.99,
) // => 1634.094

Spireを使わない場合はけっこう大きな誤差が出たのに対して、Spireを使った場合は誤差なく計算ができている。気になる人は手で計算してみよう。

ほかの型

Spireはほかにも、AlgebraicComplexといった面白い型をたくさん用意してくれている。興味が湧いた人は調べてみよう。

あわせて読みたい

kmizu.hatenablog.com

僕は十進小数をデフォルトにすることに反対しているという認識はなかったのでちょっとズレていると思います。場合によっては十進小数にも限界がある、Scalaではこういうやり方があるよね、という話をしていたつもりだった。

qiita.com

元ツイートに関連した記事です。輩と呼ぶのはいささか言いすぎと思いますけど言ってることはだいたいそうだと思います。

rabit-gti.hatenablog.com

ハードウェアの要素もあるのを忘れたらアカンやろ、という記事。我々のPCでfloatの計算が速いのはそういうコプロセッサが載ってるからなんですよね。十進コプロセッサがマシンに載るようになったらちょっと世の中は変わるかもしれないね。

★記事をRTしてもらえると喜びます
Webアプリケーション開発関連の記事を投稿しています.読者になってみませんか?