2021/08/05(木)調和級数の部分和で遊んでみた

はじめに

次のようなツイートが話題になったことがありました。
調和級数
H_n = \sum_{k=1}^n \frac{1}{k} = 1 + \frac{1}{2} + \frac{1}{3} + \cdots + \frac{1}{n}
が、n \to \inftyのとき無限に発散することはよく知られています。この発散は非常に遅く、30を超える最小のnはかなり大きな数になります。これを計算で求めようと思うと、計算時間の問題もありますが、何よりも丸め誤差の蓄積の影響が馬鹿にならず、正しいnを求めるのは案外大変です。

以下、xに対してH_{n-1}<xだがH_n \ge xとなるようなnF(x)と書くことにします。つまり、F(30)を計算したい

精度保証せずに普通に計算

次の簡単なプログラムで、自然数なxに対してF(x)を計算できます。
#include <iostream>
#include <cmath>

int main()
{
	double s, tmp;
	long long int i;
	std::cout.precision(17);

	s = 0;
	i = 1;
	while (true) {
		tmp = s + 1.0 / i;
		if (floor(tmp) != floor(s)) {
			std::cout << i-1 << ":" << s << "\n";
			std::cout << i << ":" << tmp << "\n";
		}
		s = tmp;
		i++;
	}
}
計算結果は、
xF(x)
24
311
431
583
6227
7616
81674
94550
1012367
1133617
1291380
13248397
14675214
151835421
164989191
1713562027
1836865412
19100210581
20272400600
21740461601
222012783315
235471312310
2414872568832
2540427833562
26109894245237
27298723533129
28812014582525
292207275035877
306000125006292
となりました。果たしてこれは正しいでしょうか?

区間演算してみる

kvライブラリを使って、区間演算でこれが正しいかチェックしてみます。部分和を区間演算して、xを「またいで」いないかどうかチェックします。
#include <kv/interval.hpp>
#include <kv/rdouble.hpp>
#include <kv/dd.hpp>
#include <kv/rdd.hpp>

typedef kv::interval<double> itv;
// typedef kv::interval< kv::dd > itv;

int main()
{
	itv s, tmp;
	long long int i;
	std::cout.precision(17);

	s = 0;
	i = 1;
	while (true) {
		tmp = s + 1.0 / itv(i);
		if (floor(tmp.lower()) != floor(tmp.upper())) {
			std::cout << "warning\n";
			std::cout << i-1 << ":" << s << "\n";
			std::cout << i << ":" << tmp << "\n";
			break;
		} else if (floor(tmp.lower()) != floor(s.lower())) {
			std::cout << i-1 << ":" << s << "\n";
			std::cout << i << ":" << tmp << "\n";
		}
		s = tmp;
		i++;
	}
}
これを実行すると、x=16のときは
\begin{eqnarray*} && H_{4989190} \in [15.999999890595899,15.999999899456953] \\ && H_{4989191} \in [16.000000091029196,16.000000099890251] \end{eqnarray*}
となって間違いなくF(16)=4989191と証明されました。しかし、x=17のときは、
\begin{eqnarray*} && H_{13562026} \in [16.999999921467317,16.999999960785197] \\ && H_{13562027} \in [16.999999995202607,17.00000003452049] \end{eqnarray*}
となってしまい、F(17)=13562027とは断定できないことが分かりました。

なお、このプログラムは単に和を左から足していますが、すべて正のたくさんの数を足すときは、小さい順に足した方が丸め誤差が小さくなり、区間演算の場合は区間の幅が小さくなることが知られています。大体のnが分かっているならば、逆順に足すことによってもう少し頑張れて、F(17)=13562027F(18)=36865412まで証明することができます。

double-double区間演算で頑張る

倍精度ではF(16)まで、逆順に足す工夫をしてもF(18)までしか証明できませんでした。kvライブラリはdouble型を2つ束ねた疑似4倍精度で区間演算ができるので、それを使って頑張ってみました。上に挙げたプログラムの最初の方のtypedef行を変更するだけです。これで計算すると、x=30
\begin{eqnarray*} && H_{6000022499692} \in [29.999999999999855,29.999999999999856] \\ && H_{6000022499693} \in [30.000000000000021,30.000000000000022] \end{eqnarray*}
となり、F(30)=6000022499693と判明しました。最初のtweetを見て計算してみた人は何人かいたようですが、やはり丸め誤差まみれになってしまってぴったりの値を計算するのは難しかったようです。ただ、この計算にはOpenMPを使って並列化しても55時間ほどかかりました(汗)。

計算結果をまとめておきます。
xF(x)
24
311
431
583
6227
7616
81674
94550
1012367
1133617
1291380
13248397
14675214
151835421
164989191
1713562027
1836865412
19100210581
20272400600
21740461601
222012783315
235471312310
2414872568831
2540427833596
26109894245429
27298723530401
28812014744422
292207284924203
306000022499693
こうして見ると、精度保証しない倍精度での計算結果は、F(23)までは(偶然)正しく、F(24)以降は間違っていたことが分かりました。下の赤字のところが間違っています。
xF(x)
235471312310
2414872568832
2540427833562
26109894245237
27298723533129
28812014582525
292207275035877
306000125006292

もうちょっと数学っぽく

有名なOEISを調べてみると、この数列はA002387に見つかりました。そこにはF(28)=812014744422まで載っていました。また、そのページからリンクされたここにはF(2303)まで(!)載っていますが、これが証明されたものかどうかは分かりません。自然数n>1に対して
F(n) = \lfloor \exp(n - \gamma) + \frac{1}{2} \rfloor
(\lfloor\rfloorはガウス記号、\gammaはEuler's gamma)という予想があるようなので、これで計算したものなのかも。

また、ガンマ関数の対数微分であるディガンマ関数
\mathrm{digamma}(x) = \frac{d}{dx}\log\Gamma(x) = \frac{\Gamma'(x)}{\Gamma(x)}
は、漸化式
\mathrm{digamma}(x+1) = \mathrm{digamma}(x) + \frac{1}{x}
を満たすことが知られており、これから直ちに、
H_n = \sum_{k=1}^n \frac{1}{k} = \mathrm{digamma}(n+1) - \mathrm{digamma}(1)
が従います。よって、与えられたxに対して、
n = \lfloor \exp(x - \gamma) + \frac{1}{2} \rfloor
で予測して、H_{n-1}H_nを精度保証付きのディガンマ関数で計算し、
H_{n-1} < x \le H_n
が満たされていればF(x)=nが証明されたことになります。

幸いなことにkvライブラリには精度保証付きのディガンマ関数があるので、これを実装してみたところ、まったく精度が出ず話になりませんでした。原因はちょっと考えたらすぐ分かって、そもそもkvのディガンマ関数は
\mathrm{digamma}(x+1) = \mathrm{digamma}(x) + \frac{1}{x}
の漸化式を用いて1 \le x < 2の範囲に引き戻して計算する実装になっていて、何のことはない、ただ調和級数の部分和を計算するだけになっていたのでした。そこで、kv-0.4.52ではもうちょっと真面目にディガンマ関数を実装してみたという話でした。詳細はガンマ関数の精度保証付き計算メモの3章に書きましたが、簡単に言えば、xを引き戻した上で
\mathrm{digamma}(x) = \int_0^\infty \left( \frac{e^{-t}}{t} - \frac{e^{-xt}}{1-e^{-t}} \right) dt
を精度保証付き数値積分で計算していたのを、引き戻しを行わずに、
\mathrm{digamma}(x) = \log x - \frac{1}{2x} - 2 \int_0^\infty \frac{t}{(t^2+x^2)(e^{2 \pi t}-1)}dt
を使って計算するようにしました。
#include <iostream>
#include <kv/interval.hpp>
#ifdef DD
#include <kv/dd.hpp>
#include <kv/rdd.hpp>
#endif
#include <kv/gamma.hpp>

#ifdef DD
typedef kv::interval<kv::dd> itv;
#define MAX 66
#else
typedef kv::interval<double> itv;
#define MAX 32
#endif

int main() {
	#ifdef DD
	std::cout.precision(34);
	#else
	std::cout.precision(17);
	#endif

	itv gamma, x, y;
	int i;

	gamma = - kv::digamma(itv(1.));

	for (i=2; i<=MAX; i++) {
		std::cout << i << "\n";

		x = i;
		y = floor(mid(exp(x - gamma) + 0.5));
		std::cout << y.lower() << "\n";
		std::cout <<  kv::digamma(y) + gamma << "\n";;
		std::cout <<  kv::digamma(y + 1) + gamma << "\n";;
	}
}
新しいディガンマ関数を用いて計算すると、倍精度で
\begin{eqnarray*} && H_{16309752131261} \in [30.99999999999995,30.999999999999997] \\ && H_{16309752131262} \in [31.000000000000014,31.000000000000061] \end{eqnarray*}
となってF(31)=16309752131262が、double-doubleを使うと、
\begin{eqnarray*} && H_{9516116398696941841501814186} \in [64.99999999999999999999999999997317,64.99999999999999999999999999999567] \\ && H_{9516116398696941841501814187} \in [65.00000000000000000000000000007849,65.00000000000000000000000000010098] \end{eqnarray*}
となってF(65)=9516116398696941841501814187まで証明できました。

数学としては何の意味もない問題でしょうけど、こういうのは楽しいですね。

2021/08/05(木)kv-0.4.52

kvライブラリを0.4.52にアップデートしました。

変更点は以下の通りです。
  • test-rounding.ccで、非正規化数を強制的に0にする"flush to zero"モードになっていないかどうかをチェックするようにした。
  • iccでコンパイルできるように修正。
  • optimize.hppで、「端にない」区間で傾きが単調ならばそこに最小値は存在しない というチェックを入れる改良を行った。
  • defint-newtoncotes.hpp, double-newtoncotes.hppで、最適な分割数の 推定がうまくできていなかったバグを修正。
  • defint.hpp, defint-singular.hppで、急減少関数などの特定の被積分関数 に対してstep sizeがうまく計算できていなかった問題を修正。
  • psa.hppの中のinv関数を、係数のオーバーフローが発生しづらいように改良。
  • gamma.hppの中のdigamma関数の計算アルゴリズムをより高精度なものに変更。
  • その他細かい修正。
iccはデフォルトで非正規化数を強制的に0にする"flush to zero"になっているので、そのままだと区間演算の結果が異常になってしまいます。例えば、上向き丸めモードで2^{-1022}/2を計算すると、0になってしまいます。すなわち、IEEE754に従っていません。オプション-no-ftz (windowsなら/Qftz-)を付けるとこの問題は回避できるようです。iccを使うときは、test-rounding.ccをコンパイル、実行してチェックしてみて下さい。

また、digammaを高精度化したのは、ある問題を解くのに必要になったためです。忘れないうちにその話も記事にするつもりです。

2021/03/20(土)kv-0.4.51

ものすごく久しぶりに、kvライブラリを0.4.51にアップデートしました。

変更点は以下の通りです。
  • 区間演算を行うinterval.hppにfloorとceilを追加。
  • 区間演算と領域の再帰的分割による最適化を行うoptimize.hppが場合によって正しく終了しなかったバグの修正。
  • 高階微分を計算するhighderiv.hppが定数関数を渡されたとき動作しなかったバグの修正。
  • 昨年11月のNVR2020の発表で作成した、複合Newton-Cotes公式を用いた数値積分のための関数群を追加。
大した変更や追加はないのですが、コロナ対応での多忙で時間が取れず、3月になってようやく少し時間が出来たので思い出しながらファイルを整理し、公開できる状態までもっていきました。数値積分のところのドキュメントに手間取りました。

highderiv.hppとoptimize.hppはともに数値積分の誤差項の計算に使われており、昨年11月の発表の際にいろいろ使ってみて不具合が発覚しています。やはり使われて鍛えられないとライブラリの完成度は上がらないので、ご利用下さると大変嬉しいです。

2020/02/12(水)kv-0.4.50

kvライブラリを0.4.50にアップデートしました。

今回は、卒論修論のシーズンが終わって、学生たちがライブラリを使うことで発見してくれた問題点の解決が主な内容です。利用して下さり、問題点を見つけてくれた学生のみなさまに感謝します。

内容は、
  • dd (double-double)用のfrexpが、入力が2のベキ乗よりも僅かに小さい値のときに正しく動作していなかったバグの修正。
  • interval.hppで、内部型としてfrexpが誤差を含む可能性があるような型を用いていたとき (例えばdd)、logがごく稀に正しい値を返していなかったバグの修正。
  • ddの(精度保証付きでない)sinとcosで、引き戻し処理が雑だったのを修正。
の3点です。特に、2番目は精度保証が破れている可能性があるため、重大な修正です。

ddのfrexpのバグ

frexpは、
    double x, y;
    int i;
    y = frexp(x, &i);
のように呼び出すと、xを入力として 0.5 ≤ |y| < 1, x = y × 2iを満たすようなyとiを計算してくれる関数で、(IEEE754の指数部とは1ずれるが)指数部を取り出してくれる関数としてよく用いられます。ddに対しても、
    kv::dd x, y;
    int i;
    y = frexp(x, &i);
のように同様に使える関数を用意していました。これの実装は、
    friend dd frexp(const dd& x, int* m) {
        double z1, z2;
        z1 = std::frexp(x.a1, m);
        z2 = std::ldexp(x.a2, -(*m));
        return dd(z1, z2);
    }
のようなものでした。しかしこれだと、xが(1, -2-54)のように2のベキ乗よりわずかに小さく、2のベキ乗と小さな負の数の和で表現されている場合、指数部を正しく取り出すことが出来ません。これを、
    friend dd frexp(const dd& x, int* m) {
        double z1, z2;
        z1 = std::frexp(x.a1, m);
        z2 = std::ldexp(x.a2, -(*m));
        if ((z1 == 0.5 && z2 < 0) || (z1 == -0.5 && z2 > 0)) {
            z1 *= 2;
            z2 *= 2;
            (*m)--;
        }
        return dd(z1, z2);
    }
のように修正する必要がありました。完全に作者の見落としであり、これを見つけてくれた学生には感謝しかないです。このバグが発生する確率は非常に低いので、かなりしつこく追い込まないと見つからない種類のバグだと思います。これにより、x = 1-2-54に対して、
x: 0.999999999999999944488848768742172978818416595458984375
y: 0.4999999999999999722444243843710864894092082977294921875
i: 1
のように誤った(0.5 ≤ |y| < 1を満たしていない)値を返していたのに対して、
x: 0.999999999999999944488848768742172978818416595458984375
y: 0.999999999999999944488848768742172978818416595458984375
i: 0
のように正しく計算されるようになりました。

interval<dd>のlogのバグ

前のddのfrexp問題に対処しているうちに、ある問題点に気付きました。それは、例えばx = 1+2-1074みたいな上位と下位の数が非常に離れたdd数に対して、誤差無しでfrexpを実行することが不可能であるという問題です。このxに対するfrexpは、
  • y = 0.5+2-1075
  • i = 1
となるべきですが、2-1075を今のdoubleで表現することは出来ません。よって、必然的に誤差が生じることになります。

frexpなんてあまり使わないし、誤差が生じることを断っておけばいいか、くらいに思ったのですが、ライブラリ内でfrexpを使っている箇所を探していたらありました、数学関数logです。log(x)を計算するのに、x=y×2iのように分解する方法を使っていました。そして、frexpに誤差が発生する可能性を考慮しておらず、frexpの戻り値を信用してそのまま計算していました。実際、doubleやmpfrではfrexpで誤差が発生することは無いのですが、ddだとこれが問題になります。

実際、log(1+2-1074)をinterval<dd>で計算すると、
[0,0]
のように誤った(真値を含まない)値が計算されました。frexpは、返却値のうち仮数部yが問題であり、指数部iは信頼できるので、指数部iのみ使って、yは改めて区間演算するように精度保証付きlogの計算方法を改めることで対処しました。これで、
[0,9.881312916824930883531375857364427447301196052286495288511713650013510145404
17503730599672723271984759593129390891435461853313420711879592797549592021563756
25260142638062280905569163433569796420737743727211399746144610001277481830712996
87746249467945463392302800634307707961482524771311823420533171133735363740791206
21249863890543182984910658610913088802254960259419999083863978818160833126649049
51429573802945356031871047722310026960705298694403875805362142149834066644536895
06671441664863872184765786916736120212023012339619506156684554636658495809965049
46155275185449574931216955640746893939906729403594535543517025132110239826300978
22029020757254763345019116747794671979873296198823284114052741805584855350891304
5817507736501283943653106689453125e-324]
のように正しい値を含む区間が返されるようになりました。

一応表現可能とはいえ、1+2-1074のような上位と下位が大きく離れたdd数が生成されることは稀であり、このバグに遭遇する確率は極めて低そうですが、可能性がゼロでない限り精度保証付き数値計算のライブラリとしては大きな問題と言えます。発見できて幸運でした。

ddのsin,cosの引き戻し問題

こちらは、精度保証付きのsin,cosでなく、sin,cosにintervalでない素のddを入れたときの問題です。元々ddに数学関数は実装されていなかったのですが、近似計算とはいえあれば便利だろうと、後で割と雑に実装したものです。が、sin,cosの引数を0近辺に引き戻すのに、2πを何度も引き算するようなあまりに雑な実装になっていました。多分後で直そうとして忘れていたものと思われます。元々精度保証は何も考えていない部分ですが、これだと大きな引数のときに計算時間が問題になりそうです。ちゃんと割り算するように直しました。

おわりに

ddは本当に鬼門で、何度も予想もしないバグに遭遇します。

精度保証付き数値計算のライブラリは、バグがあれば当然精度保証にならないわけで、プログラムにバグがないことをどうやって保証するのか、という宿命的な問題を抱えています。数学の定理の自動証明や、プログラムの自動検証を行うような研究もなされています。

(将来は分かりませんが)現在のところ、数学の定理が正しいかどうかは、多くの数学者が証明を検証してどうやら正しそうだ、と思えたら正しい、という段階のようです。プログラムも同じことで、大勢の人が使い、またソースコードを見て、どうやら正しそうだと思えたら正しい、とするしかないように思います。そのために、精度保証付き数値計算のプログラムは必ずオープンでなければならないし、また大勢の人に使われなければならないと考えています。

2019/11/21(木)kv-0.4.49

久しぶりに、kvライブラリを0.4.49にアップデートしました。

今回は、以前から溜めていた、
  • 辺に特異性を持つ2変数関数の二重積分
  • 一点に特異性を持つ2変数関数の二重積分
を精度保証付きで行う機能を追加しました。

言葉で書くと簡単そうですが苦労しています。数値積分のページの第5,6節とか、辺及び一点にベキ型特異性を持つ2変数関数の二重積分を見て下さい。
OK キャンセル 確認 その他