Keyboard shortcuts

Press or to navigate between chapters

Press ? to show this help

Press Esc to hide this help

はじめに

本書は、プログラミング言語Rustを用いた計算物理学の実践的な入門書です。数値計算の基礎から物理シミュレーションの実装までを体系的に解説し、Rustを用いた科学技術計算の基礎を身につけることを目的とします。

本書の目的

計算物理学は、現代の物理学研究において不可欠な手法です。解析的に解くことが困難な問題に対し、数値計算を用いることで具体的な解を得ることができます。一方、Rustは安全性とパフォーマンスを両立したプログラミング言語として注目を集めています。

本書は、これら2つの分野を結びつけ、Rustを用いて計算物理学の基本的な手法を実装することを通して、その原理の理解を深めることを目的としています。本書が、読者の皆様ご自身による物理シミュレーションの設計・実装、そして結果の分析の一助となることを目指します。

本書では、AI agent を用いた開発を推奨します。定型的な実装、入出力、テスト雛形、プロット用スクリプト、エラー調査、差分確認などは agent に任せることができます。一方で、生成されたコードの物理的・数値的な正しさは自動的には保証されません。本書では、Rust、数値計算、物理の基本概念をもとに、agent の出力を読み、実行し、検証する力を重視します。

想定読者

本書は、以下のような読者を想定しています。

  • Rustを学びながら計算物理を実装したい方:変数、関数、所有権といったRustの基本概念を、必要に応じて公式ドキュメントを参照しながら学べる方を対象とします。高度なRustの知識は前提としませんが、簡単なプログラムを書いた経験があることを想定しています。

  • 理工系の学部上級生から大学院修士初年程度の方:力学、電磁気学、熱力学・統計力学、量子力学といった基礎的な物理学の知識を前提とします。ただし、各章では必要に応じて関連する物理概念の簡単な復習を行います。

プログラミングや物理学の専門家である必要はありません。これらの分野を学びながら実践的なスキルを習得したい学生や研究者の皆様に最適な内容です。

Note

Rustを未習得の方へ

Rustに初めて触れる方は、本書と並行して、公式ドキュメントを必要に応じて参照してください。公式ドキュメントは非常に丁寧に記述されており、Rustの基本概念を体系的に学ぶことができます。

特に、変数と可変性、所有権、構造体、列挙型、パターンマッチ、トレイトなどの章は、本書を読み進める上で重要な基礎となります。最初にすべてを完全に理解する必要はありません。演習で必要になった箇所を戻って確認する進め方で十分です。

本書の特徴

本書には、以下のような特徴があります。

  • 実践重視のアプローチ:理論的な解説も行いますが、それ以上に、実際にコードを記述して実行し、結果を検証するプロセスを重視しています。各章で具体的な実装例を提示し、読者の皆様がご自身の手で試すことができるように構成しています。

  • 体系的・網羅的な内容:数値計算の基礎に始まり、常微分方程式、偏微分方程式、モンテカルロ法、並列計算、可視化に至るまで、計算物理学の主要なトピックを幅広く網羅しています。

本書で扱う内容

本書は、大別して以下の4部構成です。

  • 第1部 基礎編:Rustと計算物理学の関係、開発環境、計算機の基本モデル、 Rustで数値計算を書くための最小セット、多次元データ、ndarray、データ保存について解説します。

  • 第2部 数値計算手法:科学計算の核となる技術を扱います。数値微分・積分、非線形方程式、線形代数、フーリエ解析、常微分方程式、偏微分方程式、モンテカルロ法などの数値解法を実装し、その特性の理解を深めます。

  • 第3部 物理シミュレーション:実際の物理現象をシミュレートします。古典力学、流体力学、統計力学(イジング模型など)、量子力学といった、多様な物理分野における計算手法を解説します。

  • 第4部 高度なトピック:より実用的なシミュレーション技術を扱います。並列計算による高速化、SIMD最適化、パフォーマンス測定などを通じて、実践的な技術を習得します。

本書の使い方

本書は、基本的に最初の章から順に読み進めることを推奨します。ただし、特定のトピックにご興味がある場合は、必要な章から読み始めることも可能です。第1部(基礎編)は、それ以降の章における前提知識となる項目が多いため、一度目を通されることを推奨します。

各章は、概ね以下の構成で展開されます。

  1. 概念の理解:トピックの物理的・数学的背景を簡潔に解説します。
  2. 実装:Rustによる具体的な実装方法を示します。
  3. 結果保存と可視化:コードを実行し、得られた結果を保存して確認します。
  4. 考察:結果の解釈や、手法の特性について考察します。

掲載されているコードは、ご自身の手で実行してみることを強く推奨します。エラーへの対処や、期待した結果が得られない場合の試行錯誤は、学習における重要なプロセスです。こうした経験を通じて、より深い理解を得ることができます。

本書について

本書は、櫻庭が卒業研究として作成したRust計算物理学チュートリアルをもとに、 品岡寛が全体構成、科学的内容、演習、AI agent を用いた開発方針を拡張したものです。

執筆にあたり、AI(大規模言語モデル)を活用していますが、生成された内容はすべて著者によって事実確認およびコードの動作検証が行われています。AIは執筆を効率化するための補助的なツールとして用いており、内容の正確性については著者が責任を負います。

本書が、これから計算物理学を学ばれる皆様や、Rustを用いた科学技術計算にご興味をお持ちの方々にとって、有益な一助となれば幸いです。

フィードバックと貢献

本書の内容には万全を期しておりますが、誤字脱字、理論的な誤り、 あるいはコードの不具合などが含まれている可能性がございます。 もし何かお気付きの点や改善のご提案がございましたら、 GitHubリポジトリ へのIssueの作成、またはPull Requestを送付いただけますと幸いです。 ご報告は日本語で問題ございません。 皆様からのフィードバックは、本書の品質向上に不可欠です。

また、「この説明が理解しにくい」「このようなトピックも扱ってほしい」といったご意見も歓迎いたします。読者の皆様と共に、より良い教材を構築していければ幸いです。

ライセンス

本書は、CC0 1.0 Universal (CC0 1.0) Public Domain Dedicationライセンスの下で公開されています。著作者は、著作権法上認められる最大限の範囲で、本書に対する著作権および関連する権利を放棄しています。本書の内容は、教育、研究、商用を問わず、自由に利用、改変、再配布が可能です。

それでは、Rustによる計算物理学の世界をお楽しみください。

第1章 Rustと計算物理学

本章は、プログラミング言語Rustを用いた計算物理学の学習を開始するための導入部です。 本章では、なぜ本書でRustを使うのか、どのように開発環境を整えるのか、計算結果をどのように保存・可視化するのかを確認します。

本書では、Rustを「人間がすべて手で書くための言語」としてだけではなく、AI agent が生成・修正したコードをコンパイラ、テスト、差分確認の中で検査しやすい言語として扱います。重要なのは、コードを書く速度そのものではなく、物理モデル、数値計算法、結果保存、検証を再現可能な形で進めることです。

本章の構成

本章は、以下の内容で構成されます。

  • なぜRustなのか? Rustの型システム、所有権、Cargo、テストが、計算コードとAI agent の出力を検査する足場になることを説明します。

  • 開発環境のセットアップ Rust toolchain、cargognuplot、エディタ、推奨するAI coding agent の位置づけについて確認します。

  • 数値計算結果の可視化 Rustで計算し、結果をCSVやバイナリ形式で保存し、保存済みデータから図を作る方針を説明します。表形式データではgnuplotを標準の可視化手段として使います。

  • 本書の使い方 各章を読む前に確認すべき問題設定、入力と出力、検証方法、AI agent に任せる作業と人間が確認する作業を整理します。

本章を終えることで、Rustによる数値計算を始めるための環境と、本書を読み進めるための基本的な作業方針が整います。

なぜRustを用いるのか

従来、計算物理学の分野では、Fortran、C/C++、Python、Julia、MATLABといったプログラミング言語が広く利用されてきました。これらの言語にはそれぞれ長所があり、特にPythonは科学技術計算におけるエコシステムが非常に充実しています。

AI agent を用いてコードの作成や修正を補助する時代には、言語選択の基準が少し変わります。人間がすべてのコードを手で書きやすいかだけでなく、人間が読みやすいか、生成されたコードを検査しやすいか、誤った変更をコンパイラやテストで早く止められるかが重要になります。

本書でRustを採用する理由は、AI agent がRustコードを常に正しく生成できるからではありません。むしろ、Rustの型システム、所有権、借用、明示的な可変性、Cargo、cargo testが、生成されたコードを検査するための強い足場になるからです。

AI agent 時代におけるRustの利点

1. 生成コードをコンパイラで制約できる

計算物理のコードでは、配列、状態変数、乱数生成器、作業用バッファ、出力データなど、多くのデータを扱います。これらのデータをいつ変更してよいのか、どこで共有してよいのかが曖昧になると、非常に追跡しにくいバグが生じます。

C/C++では、aliasing、メモリの寿命、可変な共有、データ競合に起因する問題が、実行時のまれな条件で初めて現れることがあります。Rustでは、所有権、借用、ライフタイム、Send / Sync などの仕組みにより、この種の問題の多くをコンパイル時に検査できます。

これは、AI agent が生成したコードを無制限に信用するのではなく、コンパイラとテストによるハーネスの中で扱うという意味で重要です。cargo checkで型や借用の問題を早く見つけ、cargo testで小さい計算部品を検証し、git diffで変更内容を確認する、という流れを作りやすくなります。

ただし、コンパイルが通ることは、物理的・数値的に正しいことを意味しません。数式、単位、境界条件、刻み幅、乱数 seed、保存量、収束性などは、読者自身が問題設定に立ち返って検証する必要があります。

2. 見た目の複雑さはレビュー不能性ではない

Rustのコードは、PythonやJuliaのコードに比べて複雑に見えることがあります。型、参照、mutResult、エラー処理、モジュール境界など、数値計算の式そのもの以外の記述が多いためです。

しかし、その多くはコンパイラに渡す制約や、コードの意図を明示するための情報です。ロジックを追うときは、まず物理量の更新、ループ、境界条件、保存する値に注目し、型注釈やエラー処理の定型部分を一旦脇に置くことができます。むしろ、どの値が可変か、どこで所有権が移るか、どの失敗を扱うかが明示されているため、曖昧なコードよりもレビューしやすい場面があります。

AI agent を使う場合は、レビューの補助も依頼できます。例えば、「この関数を疑似コードにして」「物理量の更新ロジックだけを抜き出して」「この Result はどの失敗を表しているか説明して」と指示すれば、Rust特有の記述とアルゴリズム本体を分けて確認できます。

3. 学習コストの意味が変わる

Rustは、学習コストが高い言語として知られてきました。所有権、借用、ライフタイム、トレイトなどは、他の多くの手続き型言語にはない概念です。従来は、細かい記法やコンパイラエラーへの対応そのものが、Rustを使う上で大きな負担になりがちでした。

しかし、AI agent を用いる開発では、コードを最初から最後まで人間が手で書くことだけが中心ではありません。定型的な構造、入出力、テストの雛形、エラー処理、モジュール分割などは、AI agent に任せることができます。その分、人間は、物理モデル、数値計算法、境界条件、検証方法、結果の解釈に集中しやすくなります。

このとき、Rustの厳しさは単なる欠点ではなくなります。コンパイラが細かい誤りを指摘し、AI agent が修正案を出し、人間が物理・数値計算として妥当かを判断する、という分担が作りやすいからです。

4. エコシステム不足の見方も変わる

Pythonの科学計算エコシステムは、NumPy、SciPy、Matplotlib、pandasなどを中心に非常に成熟しています。一方、Rustの科学技術計算エコシステムは発展途上であり、特定の数値計算アルゴリズムや専門分野に特化したクレートが存在しない場合もあります。

これは現実的な課題です。ただし、AI agent を用いると、薄い wrapper、入出力コード、テスト、保存済みデータからのプロットなどは、以前よりも短い時間で用意しやすくなります。すべての機能が既にクレートとして揃っているからRustを使うのではなく、不足している部分を検査可能な形で補いやすいことが重要になります。

従来の言語との関係

Rustは、既存の言語をすべて置き換えるものではありません。目的に応じて、以下のような違いがあります。

  • Fortran:数値計算やHPCの分野で長い実績があり、既存の数値ライブラリ資産に強みがあります。一方で、テンソルネットワークのように複雑なデータ構造、抽象化、テスト分割、依存関係管理を伴うアルゴリズムでは、設計や保守の負担が大きくなりやすい面があります。
  • C/C++:高速な実行が可能で、既存資産も豊富です。一方で、メモリ管理、aliasing、未定義動作、データ競合などの問題をプログラマが慎重に管理する必要があります。
  • Python:探索的な解析、可視化、既存ライブラリの利用に非常に強い言語です。一方、言語単体の実行速度や静的検査には限界があり、高速化が必要な箇所はC、Fortran、Rustなどで実装された外部ライブラリに依存することが多くなります。
  • Julia:高水準な記述と高い実行性能を両立しようとする言語で、数値計算のプロトタイピングにも適しています。一方、Rustのように所有権や借用を用いてメモリ共有やデータ競合をコンパイル時に厳密に制約する設計ではありません。

本書では、Rustを「最も簡単に書ける言語」としてではなく、計算コードを安全に育て、AI agent の出力を検査し、再現可能な形で残しやすい言語として扱います。

Rustの具体的な強み

安全性とパフォーマンスの両立

Rustは、所有権システムによりコンパイル時にメモリ安全性を保証しつつ、C/C++に近い実行性能を目指せる言語です。ガベージコレクタに依存しないため、実行中の予期しにくい停止を避けやすく、長時間実行する計算や性能が重要な計算に向いています。

データ競合のない並列処理

現代の計算物理学では、マルチコアCPUやGPUといった並列計算資源の活用が重要です。Rustの型システムは、スレッド間で共有してよいデータと共有してはいけないデータをコンパイル時に検査します。

例えば、データ並列ライブラリであるRayonを利用すると、イテレータを並列化しながら、データ競合を起こしにくい形でコードを書くことができます。

Cargoによる再現可能な開発環境

Rustでは、cargo newによるプロジェクト作成、cargo addによる依存関係の追加、cargo buildによるビルド、cargo testによるテスト実行といった一連の作業が、cargoという単一のツールで完結します。

科学技術計算では、どの依存関係で、どの入力条件で、どのコードを実行したのかを後から確認できることが重要です。Cargo.tomlCargo.lockにより依存関係を管理し、テストと実行コマンドを残すことで、計算の再現性を高められます。

他言語との連携

Rustからは、Fortran、C、C++で実装された既存の数値計算ライブラリを呼び出すこともできます。過去の資産をすべて捨てるのではなく、必要に応じて利用できる点も実用上の利点です。

使い分け

Rustにも向き不向きはあります。学習コストは依然として存在しますし、Pythonほど科学計算ライブラリが網羅的に揃っているわけではありません。

本書では、Rustで計算を行い、表形式データはCSV、多次元配列や大きなデータはHDF5やNumPy形式、実行条件はmetadataとして保存し、プロットは保存済みデータから作成する流れを基本にします。この方法では、入力パラメータ、乱数 seed、実行条件、出力ファイルを明示的に残せるため、結果の再現性や検証がしやすくなります。入出力やプロット用の定型的なコードは、AI agent に自然言語で指示して任せることができます。

迅速なデータ分析、使い捨てのスクリプト、既存Pythonライブラリに強く依存した解析では、PythonやJuliaを使う方が自然な場合もあります。一方でRustは、長時間実行する計算、性能やメモリ効率が重要な計算、並列化したい計算、長期的に保守したい計算コードで特に有用です。

まとめ

Rustは、安全性、パフォーマンス、並列処理、再現可能な開発環境という点で、計算物理学にとって魅力的な選択肢です。AI agent 時代には、さらに、生成コードをコンパイラとテストのハーネスの中で扱えることが重要になります。

AI agent は、コードを速く書く助けにはなりますが、物理的・数値的な正しさを自動的に保証するものではありません。本書では、Rustの厳密な仕組みを利用しながら、読者自身が問題設定、実装、検証、結果の解釈を行えるようになることを目指します。

Last change: , commit: 44c4572

開発環境の構築

本章では、Rustを用いて計算物理学の研究・開発を開始するための開発環境を構築します。本書は、Rustの基本文法を既に習得されている読者を対象としているため、多くの方はRustをインストール済みであると想定されます。該当する方は、本節を読み飛ばしていただいて問題ありません。

Rustをまだインストールされていない方や、環境を再確認したい方のために、標準的な構築手順を以下に解説します。

Rustのインストール

Rustをまだインストールされていない方は、Rust公式サイトの案内に従ってインストールを実行してください。公式サイトでは、お使いのOS(Windows, macOS, Linux)に応じた最新のインストール手順が提供されています。

Rust公式のツールチェーン管理ツールであるrustupを使用することで、Rustコンパイラのバージョン管理や、関連ツールの更新を容易に行うことができます。

インストールの確認

Rustが正しくインストールされているかを確認するため、ターミナルで以下のコマンドを実行してください。

rustc --version
cargo --version

これらのコマンドがそれぞれのバージョン情報を表示すれば、インストールは成功です。

  • rustc: Rustのコンパイラ
  • cargo: Rustのビルドツール兼パッケージマネージャ

本書では、プロジェクトの作成や管理にcargoを主に利用します。

Rustのバージョンについて

本書では、Rustの安定版(stable)を使用することを推奨します。Rustは後方互換性が重視されているため、多くの場合、少し古いバージョンでも本書のコードは問題なく動作します。

古いバージョンのRustを使用していてコンパイルエラーが発生した場合は、以下のコマンドで更新してください。

rustup update

特定のバージョンを使う必要がある場合は、rust-toolchain.tomlなどでプロジェクトごとに固定できます。ただし、本書を読み進める上では、まずstableを使えば十分です。

可視化ツールの確認

本書では、表形式のデータをCSVに保存し、gnuplotで可視化する流れを標準とします。gnuplotが利用できるかは、次のコマンドで確認できます。

gnuplot --version

インストールされていない場合は、お使いのOSのパッケージマネージャや公式サイトの案内に従って導入してください。

Pythonのmatplotlib、HDF5、NumPy形式(.npy.npz)は必須ではありません。複雑なデータ加工、多次元配列、大きなデータを扱う章や発展課題で、必要に応じて利用します。

開発環境の動作確認

次に、簡単なプログラムを作成・実行し、開発環境が正しく動作することを確認します。ここでは、本書のテーマに合わせて、簡単な数値計算を行うプログラムを作成します。

プロジェクトの作成

まず、新しいプロジェクトを作成します。任意のディレクトリで以下のコマンドを実行してください(physics_testの部分は任意のプロジェクト名に変更可能です)。

cargo new physics_test
cd physics_test

cargo newは、新しいRustプロジェクトの雛形を生成するコマンドです。physics_testという名前のディレクトリが作成され、その中に以下のようなファイル構造が生成されます。

physics_test/
├── Cargo.toml     # プロジェクトの設定ファイル(依存関係など)
└── src/
    └── main.rs    # メインのソースファイル

簡単な数値計算プログラムの作成

src/main.rsをエディタで開き、以下の内容に書き換えてください。このプログラムは、自由落下する物体の位置と速度を時系列で計算するものです。

fn main() {
    // 物理定数
    let g = 9.8; // 重力加速度 [m/s^2]
    let v0 = 0.0; // 初速度 [m/s]
    let h0 = 100.0; // 初期高度 [m]

    println!("自由落下シミュレーション");
    println!("初期高度: {} m", h0);
    println!("重力加速度: {} m/s^2", g);
    println!();

    // 時刻ごとの位置と速度を計算
    println!("時刻[s]  高度[m]  速度[m/s]");
    println!("================================");

    for i in 0..=10 {
        let t = i as f64 * 0.5; // 0.5秒刻み
        let v = v0 - g * t; // 速度 v = v0 - gt
        let h = h0 + v0 * t - 0.5 * g * t * t; // 位置 h = h0 + v0*t - (1/2)*g*t^2

        if h >= 0.0 {
            println!("{:6.1}  {:7.2}  {:9.2}", t, h, v);
        } else {
            // 地面に到達した場合、正確な到達時刻と速度を算出して終了
            let t_impact = (v0 + (v0 * v0 + 2.0 * g * h0).sqrt()) / g;
            let v_impact = v0 - g * t_impact;
            println!("{:6.2}  {:7.2}  {:9.2} (地面到達)", t_impact, 0.0, v_impact);
            break;
        }
    }
}

このプログラムは、以下の物理法則に基づき実装されています。

  • 速度:
  • 位置:

ここで、 は重力加速度、 は初速度、 は初期高度です。

プログラムの実行

プロジェクトのルートディレクトリ(Cargo.tomlが配置されているディレクトリ)で、以下のコマンドを実行してください。

cargo run

初回の実行ではプロジェクトのコンパイルが行われるため、多少時間を要します。コンパイルが完了するとプログラムが自動的に実行され、以下のような出力が表示されます。

自由落下シミュレーション
初期高度: 100 m
重力加速度: 9.8 m/s^2

時刻[s]  高度[m]  速度[m/s]
================================
   0.0   100.00       0.00
   0.5    98.78      -4.90
   1.0    95.10      -9.80
   1.5    88.97     -14.70
   2.0    80.40     -19.60
   2.5    69.38     -24.50
   3.0    55.90     -29.40
   3.5    39.97     -34.30
   4.0    21.60     -39.20
   4.5     0.77     -44.10
  4.52     0.00     -44.27 (地面到達)

このような出力が得られれば、開発環境は正しく構築されています。

コードの概要

上記のコードでは、Rustの基本的な機能をいくつか利用しています。

  • 変数の宣言: letキーワードで変数を宣言します。Rustの変数はデフォルトでイミュータブル(不変)です。
  • 浮動小数点数: f64型(64ビット浮動小数点数)を利用しています。多くの場合、型はコンパイラによって推論されるため、明示的な記述は不要です。
  • ループ: forループを用いて、0.5秒ごとに時間ステップを進めています。
  • 型変換: i as f64という構文で、ループカウンタの整数iを浮動小数点数に変換しています。
  • 条件分岐: if文を用いて、物体が地面に到達したか否かを判定しています。
  • 書式付き出力: println!マクロと書式指定子(例: {:6.1})を使い、整列された出力を生成しています。

このプログラムは非常に単純ですが、計算物理学の基本的な要素(物理法則の数式化、時間発展シミュレーション、結果の出力)を含んでいます。本書では、これらの要素をさらに発展させ、より複雑な物理現象のシミュレーションを扱います。

エディタとIDE

Rustのコードを記述するためのエディタや統合開発環境(IDE)は、ご自身の好みに応じて選択いただけます。多くの主要なエディタには、Rustをサポートする拡張機能が提供されています。

どのエディタを選択する場合でも、rust-analyzerのサポートを有効にすることを強く推奨します。rust-analyzerは、Rust公式の言語サーバープロトコル(LSP)実装であり、以下のような強力な開発支援機能を提供します。

  • コード補完
  • エラーチェックとハイライト
  • 定義元へのジャンプ
  • リファクタリング

これらの機能により、開発効率が大幅に向上します。

AI coding agent の利用(推奨)

本書の演習では、AI coding agentの利用を推奨します。例えば、Codex、OpenCode、Claude Code などのツールを用いることで、コードの作成や修正、入出力、テスト雛形、プロット用スクリプト、エラー原因の調査、差分の確認を任せることができます。

ただし、特定のツールを必須とはしません。各ツールのインストール方法、料金、利用条件は変わる可能性があるため、本書では詳細な手順や比較は扱いません。利用する場合は、それぞれの公式情報を確認してください。

実践的な導入資料として、CompPhysHack 2026 の講義資料と、 AtelierArith による coding agents のハンズオン資料も参考になります。 後者では、Claude Code や Cursor などの coding agent を用いた開発の進め方が、 演習を通して紹介されています。

AI coding agent は、演習用プロジェクトや本書のリポジトリのルートディレクトリで起動し、 Cargo.tomlsrcディレクトリ、テスト、差分を参照できる状態にします。 agent に任せた変更は、cargo checkcargo testで確認し、 必要に応じてgit statusgit diffgit diff HEADで変更内容を確認してください。

Rustのコンパイラとテストは、AI coding agent が生成したコードを検査するためのハーネスとして機能します。一方で、コンパイルが通ることは、物理モデル、数値計算法、単位、境界条件、乱数 seed などが正しいことを意味しません。これらは、各章で扱う理論と検証方法に立ち返って確認する必要があります。

次のステップ

以上で、開発環境の構築と、簡単な物理シミュレーションの実行が完了しました。しかし、画面への出力だけでは、結果を直感的に理解することは困難です。

次節では、シミュレーション結果をグラフとして可視化する手法について解説します。

トラブルシューティング

もしインストールや実行時に問題が発生した場合は、付録C: デバッグとトラブルシューティングをご参照ください。そこでは、よくある問題とその解決策が記載されています。

また、Rust公式のドキュメントも非常に充実しており、問題解決の一助となります。

Last change: , commit: cd5425f

数値計算結果の可視化

数値計算では、計算結果を図として確認することが重要です。ただし、本書の中心は可視化ライブラリの使い方ではなく、物理モデル、数値計算法、Rustによる実装、そして結果の検証です。

そのため本書では、まず次の流れを標準とします。

  1. Rustで計算する。
  2. 結果をCSVやバイナリ形式のファイルに保存する。
  3. 表形式データはgnuplotで可視化する。
  4. 図を見て、解析解、保存量、収束性、境界条件などを確認する。

gnuplotはテキストファイルでプロット手順を管理できるため、表形式データの確認には十分です。多次元配列では、必要な断面、射影、集計量を取り出して可視化します。複雑なデータ加工や論文用の細かい図が必要になった場合だけ、Pythonのmatplotlibなどを使います。

基本方針

本書では、計算と可視化を分離します。Rustのプログラムは、画面への出力だけでなく、後から確認できる形でデータを保存します。

この方針には、次の利点があります。

  • 入力パラメータ、乱数seed、実行条件、出力ファイルを後から確認できる。
  • 同じデータから何度でも図を作り直せる。
  • 図の作成手順をplot.gpのような短いテキストファイルとして管理できる。
  • AI agentに、入出力コードやプロット用スクリプトの作成を任せやすい。

可視化は、計算結果を検査するための手段です。図がきれいに描けることと、数値計算が正しいことは同じではありません。

CSVファイルへの出力

CSVは多くのツールで読み込めるため、時系列や表形式の小さなデータに適しています。次の例では、時刻t、位置x、速度voutput.csvに保存します。

use std::fs::File;
use std::io::Write;

fn main() -> std::io::Result<()> {
    let mut file = File::create("output.csv")?;

    writeln!(file, "t,x,v")?;

    for i in 0..=100 {
        let t = i as f64 * 0.1;
        let x = t.sin();
        let v = t.cos();
        writeln!(file, "{t},{x},{v}")?;
    }

    Ok(())
}

実行すると、次のようなCSVファイルが生成されます。

t,x,v
0,0,1
0.1,0.09983341664682815,0.9950041652780258
0.2,0.19866933079506122,0.9800665778412416

実際のシミュレーションでは、必要に応じてmetadata.jsonのような別ファイルに、パラメータ、乱数seed、格子サイズ、刻み幅、実行日時なども保存するとよいでしょう。

多次元配列とバイナリ形式

格子上の場、画像、波動関数、相関関数などの多次元配列を扱う場合、CSVはあまり適していません。ファイルサイズが大きくなりやすく、形状やデータ型の情報も別に管理する必要があるためです。

このような場合は、HDF5やNumPy形式(.npy.npz)などのバイナリ形式を検討します。これらは広く使われており、Python、Julia、C/C++、Fortranなど複数の言語から読み書きしやすい形式です。

したがって、データ形式は次のように使い分けます。

  • 時系列や少数列の表形式データ: CSV
  • 多次元配列や大きなデータ: HDF5、NumPy形式などのバイナリ形式
  • 実行条件やパラメータ: JSON、TOML、YAMLなどのメタデータファイル

gnuplotによる可視化

gnuplotでは、プロット手順をテキストファイルとして保存できます。例えば、次の内容をplot.gpとして保存します。

set terminal svg size 900,600
set output "plot.svg"
set datafile separator ","
set key autotitle columnhead

set xlabel "t"
set ylabel "value"
set grid

plot "output.csv" using 1:2 with lines linewidth 2, \
     "" using 1:3 with lines linewidth 2

次のコマンドで図を生成します。

gnuplot plot.gp

この例では、output.csvの1列目を横軸、2列目と3列目を縦軸として描画し、plot.svgを出力します。CSVのヘッダー行は凡例として使われます。

gnuplotスクリプトは短く、差分も読みやすいため、agentic codingと相性がよいです。例えば、AI agentには次のように依頼できます。

output.csvには t, energy, error が入っています。
energyの時間変化とerrorの対数プロットを別々のSVGに保存するgnuplotスクリプトを書いてください。
軸ラベル、grid、出力ファイル名も入れてください。

ただし、生成された図を確認するのは人間の役割です。軸、単位、列の対応、期待される保存量や収束性が正しいかを確認してください。

matplotlibを使う場合

Pythonのmatplotlibは、データ加工や複雑な図を作る場合に有用です。例えば、複数ファイルの集計、フィッティング、統計量の計算、論文用の細かな体裁調整を行う場合にはmatplotlibを使うと便利です。

import pandas as pd
import matplotlib.pyplot as plt

df = pd.read_csv("output.csv")

fig, ax = plt.subplots()
ax.plot(df["t"], df["x"], label="x")
ax.plot(df["t"], df["v"], label="v")
ax.set_xlabel("t")
ax.set_ylabel("value")
ax.grid(True)
ax.legend()
fig.savefig("plot.png", dpi=150)

一方で、単純な折れ線グラフや散布図であれば、まずgnuplotで十分です。可視化のためにPython環境を必須にしないことを、本書の標準方針とします。

Rust製ライブラリを使う場合

Rustだけで図の生成まで完結させたい場合は、plottersを使うこともできます。また、3D可視化やインタラクティブな表示が必要な場合には、three-dなどのグラフィックスライブラリが選択肢になります。

ただし、これらは本書の標準経路ではありません。Rust側に可視化処理を深く組み込むと、数値計算の本体と図の作成が混ざりやすくなります。まずは計算結果を保存し、外部ツールで可視化する構成を基本にしてください。

まとめ

本書では、次の方針を採ります。

Important

  • Rustは計算と結果保存を担当する。
  • 標準の可視化は、表形式データならCSVとgnuplotを使う。
  • 多次元配列や大きなデータは、HDF5やNumPy形式などのバイナリ形式で保存する。
  • matplotlibは、複雑なデータ加工や図が必要な場合の補助として使う。
  • plottersthree-dは、Rustだけで完結させたい場合や3D可視化が必要な場合の発展的な選択肢とする。
  • 図は検証の入口であり、物理的・数値的な正しさは別途確認する。

次節では、本書全体の構成と学習の進め方について解説します。

Last change: , commit: e9e841a

本書の使い方

本節は、本書を最大限に活用し、計算物理学とRustプログラミングの技術を効率的に習得するための指針を示すものです。本書は、理論の学習、コードの実装、結果の保存、そして検証と考察というサイクルを通じて、実践的な能力を養うことを目的として設計されています。

基本的な進め方:通読

計算物理学や数値計算の全体像を体系的に学習したい方には、第1章から順に読み進めることを強く推奨します。前の章で解説した概念や実装が、後の章の基礎となっていることが多いため、この方法が最もスムーズに理解を深めることができます。

もちろん、興味のある章から読み進めても構いませんが、第1部(基礎編) は本書全体の共通の基礎となるため、一度目を通しておくことを推奨します。

実装に入る前に確認すること

本書の中核は、実際にコードを動かし、結果を検証する過程にあります。ただし、計算物理のコードでは、いきなり実装から始めると、何を確認すべきかが曖昧になりがちです。

各章の演習では、まず次の点を確認してください。

  1. 問題設定:何を計算したいのか。
  2. 入力と出力:パラメータ、初期条件、保存する結果は何か。
  3. 仮定と境界条件:どの近似や境界条件を使うのか。
  4. 数値計算法:どのアルゴリズムを使い、どの量を更新するのか。
  5. データ構造:配列、状態変数、乱数生成器、結果保存をどう表すのか。
  6. 検証方法:解析解、保存量、収束性、極限ケース、既知の結果とどう比較するのか。

AI agent を使う場合でも、この確認を省略しないでください。agent に実装を任せる場合も、人間が問題設定と検証方針を持っていることが重要です。

サンプルコードの実践ガイド

本書のサンプルコードは、以下の手順で実行することを想定しています。

  1. 新しいプロジェクトを作成する ターミナルで cargo new コマンドを用いて、演習用のプロジェクトを作成します。

    cargo new physics-exercise
    cd physics-exercise
    
  2. コードを配置する 任意のテキストエディタで src/main.rssrc/lib.rs を開き、本書に掲載されているコード例を入力します。

  3. 依存関係を追加する Cargo.toml ファイルを開き、コードが必要とするクレート(ライブラリ)を [dependencies] セクションに追加します。バージョンは、本書の表記に合わせることを推奨します。

    [dependencies]
    csv = "1"
    # 章で必要なクレートだけを追加
    
  4. コンパイルと実行 ターミナルで cargo run を実行してください。Cargoが自動的に依存クレートをダウンロードし、コードをコンパイルして実行します。

    cargo run
    
  5. 確認する 実装後は、少なくとも次のコマンドを確認します。

    cargo test
    git status
    git diff
    git diff HEAD
    

    cargo test は、実装した関数や小さい検証問題が期待通りに動くかを確認するために使います。 git status は変更されたファイルの確認、git diff はまだstageしていない差分の確認、 git diff HEAD は最終commitからの変更全体の確認に使います。

AI agent を使う標準的な流れ

本書では、AI agent の利用を推奨します。ただし、いきなり「この章のコードを実装して」と頼むのではなく、まず問題設定と検証方針を整理させるとよいでしょう。

例えば、次のような順序で進めます。

  1. note を作らせる モデル定義、入力、出力、仮定、境界条件、数値計算法、検証方法をまとめさせます。

  2. implementation plan を作らせる 関数境界、module 構成、テスト、保存する結果、metadata、実行コマンドを含む計画を作らせます。

  3. 実装を任せる 定型的なコード、入出力、テスト雛形、プロット用コード、モジュール分割は agent に任せることができます。ただし、物理モデルや数値計算法として妥当かどうかは、人間が確認します。

  4. 検査する cargo test、保存された結果、プロット、git diff HEAD を確認します。コンパイルが通ることは、物理的・数値的に正しいことを意味しません。

  5. 具体的にレビューさせる 「正しいですか」とだけ聞くのではなく、次のように確認対象を指定します。

    • 解析解と比較してください。
    • 保存量が時間発展でどの程度保たれているか確認してください。
    • 刻み幅を変えたときの収束性を確認してください。
    • 境界条件の実装を確認してください。
    • random seed、入力パラメータ、出力ファイル名が保存されているか確認してください。
    • この関数を疑似コードにして、物理量の更新ロジックだけを説明してください。

結果保存とプロット

本書では、Rustで計算を行い、表形式データはCSV、多次元配列や大きなデータはHDF5やNumPy形式、実行条件はmetadataとして保存し、プロットは保存済みデータから作成する流れを基本にします。

この方法には、次の利点があります。

  • 入力パラメータ、乱数 seed、実行条件、出力ファイルを後から確認できる。
  • 計算と可視化を分離できる。
  • 同じ保存データから、何度でもプロットを作り直せる。
  • AI agent に、保存処理やプロット用コードを自然言語で任せやすい。

単に画面に数値を表示するだけでなく、後から検証できる形で結果を残す習慣をつけてください。

コードを用いた実験の推奨

コードの正常な動作を確認した後は、パラメータや初期条件を変更し、「実験」を行うことを強く推奨します。これは、内容の理解を深める上で極めて効果的な学習方法です。

  • パラメータの変更: シミュレーションのステップ数、物理定数、初期値などを変更し、結果がどのように変化するかを観察してください。
  • 題材の応用: sin(x) をプロットする代わりに、別の関数(例: cos(x) やガウス関数)をプロットしてみてください。
  • エラーからの学習: エラーは絶好の学習機会です。コンパイラが示すメッセージを注意深く読み、問題の原因を突き止めるプロセスを通じて、Rustへの理解が格段に深まります。

もしコードが期待通りに動作しない場合や、完成版のコードを確認したい場合は、本書の公式リポジトリをご参照ください。ただし、まずはご自身で原因を調べ、必要に応じてAI agent に差分やエラーの説明を求めることを推奨します。

各章の構成とヒント

本書の多くの章や節は、概ね以下の構成になっています。

  1. 理論的背景: トピックの背後にある物理的・数学的な概念を解説します。
  2. 実装: Rustによる具体的な実装方法を示します。
  3. 実行と結果保存: コードを実行し、得られた結果を保存します。
  4. 可視化と考察: 保存済みデータからグラフを作成し、アルゴリズムの特性や物理的な意味を考察します。
  5. 検証: 解析解、保存量、収束性、境界条件、metadata などを確認します。

章の最後には、学んだ知識をさらに深めるための 「発展課題」 を設けていることがあります。これらには必ずしも唯一の正解があるわけではなく、読者の皆様が自由な探求を行うための指針として提示されています。ぜひ挑戦されることを推奨します。

本書の表記ルール

本書では、情報を明確に伝達するため、いくつかの特別な表記法を用いています。

Note

NOTE (補足)

補足情報や、関連する豆知識などを記載しています。

Important

IMPORTANT (重要)

特に重要な概念や、必ず留意していただきたい事柄を示しています。

Warning

WARNING (警告)

注意を怠るとエラーや予期せぬ結果に繋がりかねない、重要な注意点を示しています。


以上で、本書の学習を開始するための準備はすべて整いました。

次章から、Rustによる数値計算の学習を開始します。

Last change: , commit: 6814f8a

計算機の基本モデル

計算物理のコードは、数式だけでなく、実際の計算機の上で動きます。 同じ計算量のアルゴリズムでも、メモリ上のデータの並びやアクセス順序によって、 実行時間が大きく変わることがあります。

本章では、Rustに入る前に、数値計算コードを読むための計算機モデルを確認します。 ここで扱う内容は Rust 固有ではありません。C/C++、Fortran、Python/NumPy でも重要です。

  • CPU、メモリ、ストレージの役割
  • latency と bandwidth
  • cache と cache line
  • 1次元データの連続アクセスと飛び飛びアクセス
  • stack と heap

本章の目的は、CPU architecture を詳しく学ぶことではありません。 以降の章で Vec<f64>、多次元配列、ndarray、PDE、行列演算、 SIMD、並列化を扱うときに、なぜデータ配置が重要なのかを理解することです。

本章の構成

本章を終えると、以降の章で出てくる配列操作や性能の話を、 プログラミング言語だけではなく計算機の動きとして読めるようになります。

CPU・メモリ・ストレージ

計算機を大まかに見ると、次の3つの場所があります。

  • CPU: 命令を実行し、四則演算や分岐を処理する。
  • メモリ: 実行中のプログラムが使うデータを置く。
  • ストレージ: ファイルとしてデータを長期保存する(SSD、HDDなど)。

数値計算では、CPUがすべての時間を計算に使っているとは限りません。 大きな配列を扱う場合、CPUはメモリからデータが届くのを待っていることがあります。 このような場合、コードの速さは浮動小数点演算の回数だけでは決まりません。

大まかな接続

現代のPCやサーバのCPUには、通常、複数のcoreがあります。 各coreが命令を実行し、複数coreを使うと並列計算につながります。 CPUの中またはCPUパッケージの近くにはcacheがあります。 CPUは、memory controller を通してDRAMとデータをやり取りします。 一方、NVMe SSDは通常PCI Express (PCIe) に接続され、 SSD内部のNVMe controllerがNAND flashを制御します。

direction: right

cpu: {
  label: "CPU package"

  cores: {
    label: "CPU cores\ncore 0, core 1, ..."
  }

  cache: {
    label: "L1/L2/L3 cache"
  }

  memctl: {
    label: "memory controller"
  }

  pcie: {
    label: "PCIe root complex"
  }

  cores -> cache
}

dram: {
  label: "DRAM\nmain memory"
}

pch: {
  label: "chipset / PCH\noptional path"
}

ssd: {
  label: "NVMe SSD"

  nvme: {
    label: "NVMe controller"
  }

  nand: {
    label: "NAND flash"
  }

  nvme -> nand
}

cpu.memctl -> dram: "memory channels"
cpu.pcie -> ssd.nvme: "PCIe lanes\nCPU-attached"
cpu.pcie -> pch: "PCIe / chipset link"
pch -> ssd.nvme: "PCIe lanes\nchipset-attached"

ここでは、CPU package、DRAM、NVMe SSDの役割の違いだけを押さえます。 計算中に頻繁に読み書きする作業場所はDRAMで、SSDはファイルを保存する ストレージとして扱います。

実行中のデータ

プログラムを実行すると、入力ファイルや実行ファイルの内容はメモリに読み込まれます。 CPUはメモリ上のデータを読み、演算し、結果をメモリへ書き戻します。

storage  ->  memory  ->  CPU  ->  memory  ->  storage
 input       arrays      calc     results     output

保存されたCSV、HDF5、NumPy形式のファイルはストレージ上にあります。 計算中の Vec<f64>ndarray::Array2<f64> はメモリ上にあります。 実際の演算はCPUで行われます。

計算量だけでは足りない

アルゴリズムを考えるとき、まず計算量を見ます。例えば、長さ n の配列の和は おおよそ O(n) の計算です。しかし、実行時間を考えるときは、 次のような要素も効きます。

  • メモリから何バイト読むか。
  • メモリへ何バイト書くか。
  • データを連続して読めるか。
  • 同じデータを何度も再利用できるか。
  • ファイル入出力が計算時間に混ざっていないか。

このため、本書では計算本体、結果保存、plot を分けて考えます。 計算の速さを見たいときに、ファイル出力やplotの時間が混ざると、 何を測っているのかが分かりにくくなります。

Last change: , commit: 4ee94dd

latency・bandwidth・cache line

メモリアクセスには、主に2つの見方があります。

  • latency: 1回の読み書きが始まってからデータが届くまでの待ち時間。
  • bandwidth: 一定時間にどれだけ多くのデータを運べるか。

小さいデータを少しだけ読む場合は latency が効きやすく、 大きな配列を連続して読む場合は bandwidth が効きやすくなります。

cache

CPUはメモリよりはるかに高速に演算できます。 その差を埋めるために、CPUの近くには cache と呼ばれる小さく高速な記憶領域があります。 よく使うデータや、近くのデータをcacheに置くことで、 CPUがメモリを待つ時間を減らします。

重要なのは、CPUがメモリから1個の f64 だけを読むとは限らないことです。 多くの計算機では、メモリは cache line と呼ばれるまとまった単位でcacheへ運ばれます。 典型的なcache lineは64バイト程度です。

f64 は8バイトなので、64バイトのcache lineには f64 が8個入ります。 つまり、a[i] を読んだとき、近くの a[i + 1], a[i + 2] も 同じcache lineに入っている可能性があります。

連続アクセスが有利な理由

配列を先頭から順に読む場合、次に必要なデータが同じcache lineか、 次のcache lineにある可能性が高くなります。 このようなアクセスは計算機にとって予測しやすく、効率的です。

一方、遠く離れた要素を飛び飛びに読む場合、cache lineで運ばれたデータの多くを 使わないまま捨てることになります。

連続アクセス:
  a[0], a[1], a[2], a[3], ...

飛び飛びアクセス:
  a[0], a[1024], a[2048], ...

数値計算では、同じ数式でも配列の並びとloop orderによって速度が変わります。 これは、数学的な演算回数だけでなく、メモリからデータをどう運ぶかが効くためです。

compute bound と memory bandwidth bound

実行時間の主な制約が浮動小数点演算である場合、その計算は compute bound です。 一方、主な制約がメモリからデータを運ぶ速度である場合、 その計算は memory bandwidth bound です。

大きな配列に対して単純な演算を1回ずつ行う処理は、memory bandwidth bound になりがちです。 同じデータをcache上で何度も再利用できる処理は、compute bound に近づくことがあります。

Last change: , commit: 23d4376

連続アクセスと1次元データ

数値計算では、長い1次元配列を順に読む処理が頻繁に出てきます。 同じ要素数を読む場合でも、メモリ上で連続して読むか、飛び飛びに読むかで 実行時間が変わることがあります。

論理メモリ番地は1次元

プログラムから見るメモリ番地は、基本的に1次元の並びです。 プログラムは通常、連続した論理アドレス空間の中にデータが置かれているように扱えます。

address:  ... 1000 1008 1016 1024 1032 1040 ...
value:        a[0] a[1] a[2] a[3] a[4] a[5]

Vec<f64> の要素は、この1次元のアドレス空間の中で連続して並びます。 f64 は8バイトなので、隣の要素へ進むことは、典型的にはアドレスを8バイト進めることに対応します。

連続アクセス

次のように Vec<f64> を先頭から順に読むと、メモリ上でも隣の要素へ進みます。

fn sum_contiguous(x: &[f64]) -> f64 {
    let mut s = 0.0;
    for value in x {
        s += *value;
    }
    s
}

CPUはメモリから1個の f64 だけを読むのではなく、周辺のデータをまとめて cache line に載せます。 連続して読むと、cache line に載ったデータを無駄なく使いやすくなります。

飛び飛びアクセス

一方、次のように一定間隔で要素を読むと、実際に使わないデータも cache line に載ることがあります。

fn sum_every_k(x: &[f64], k: usize) -> f64 {
    let mut s = 0.0;
    let mut i = 0;
    while i < x.len() {
        s += x[i];
        i += k;
    }
    s
}

このようなアクセスでは、読んだバイト数のわりに使う値が少なくなり、 メモリ帯域やcacheの効率が悪くなることがあります。 ここでいう「何要素ごとに読むか」が、1次元データでのstrideの直感です。

実際には

実際には、論理アドレスはOSやMMUによってページ単位で物理メモリに対応づけられます。 ただし、配列をどの順序で読むかを考える段階では、まず論理アドレス空間での 連続性を見れば十分です。

第4章への接続

多次元配列では、数学的な添字が2個以上あっても、実データは1次元bufferに置かれます。 row-major、column-major、stride、loop order は、 多次元配列とメモリレイアウト で扱います。

Last change: , commit: 4ee94dd

stackとheap

実行中のプログラムが使うメモリには、大まかに stackheap があります。 ここでは厳密な実装詳細ではなく、数値計算コードを読むための直感を押さえます。

stack

stack は、関数呼び出しに伴って使われる領域です。 小さい固定サイズの値や、関数の局所変数の一部が置かれます。 関数を抜けると、その関数のために使われていたstack領域はまとめて解放されます。 一方で、stack の大きさには上限があります。 Linux の一般的な環境では ulimit -s8192 KB、つまり約8 MiB に 設定されていることがよくあります。ただし、この値はOS、distribution、 shell の resource limit、thread の設定によって変わります。

例えば、次のような値は小さく、固定サイズです。

let dt: f64 = 0.01;
let n_steps: usize = 1000;
let x: [f64; 3] = [0.0, 1.0, 2.0];

数値計算では、巨大な固定長配列を局所変数として作ることは避けます。 例えば、次のような配列は f64 が100万個なので約8 MBあります。 これは stack の上限に近く、環境によっては stack overflow の原因になります。

let values: [f64; 1_000_000] = [0.0; 1_000_000];

大きな配列は、実行時に動的に確保し、heap に置くのが基本です。

heap

heap は、実行時にサイズが決まるデータや、大きなデータを置くための領域です。 数値計算で使う大きな配列は、通常heapに置かれます。

let n = 1_000_000;
let values = vec![0.0_f64; n];

Vec<f64> そのものは、長さ、容量、heap上のデータへのポインタを持つ小さな値です。 実際の100万個の f64 はheap側に置かれます。

所有権との関係

Rustでは、heap上のデータを誰が所有しているかを型システムで追跡します。 これは、後の章で扱う Vec<f64>、slice、借用の理解につながります。

  • Vec<f64> はheap上のデータを所有する。
  • &[f64] は所有せずに読む。
  • &mut [f64] は所有せずに更新する。

この区別はRust固有の文法として現れますが、背景には 「大きなデータをどこに置き、誰が変更できるか」という一般的な問題があります。

第3章では、この考え方をRustの関数境界として扱います。

Last change: , commit: 23d4376

Rustで数値計算を書く最小セット

Rustと計算物理学では、 本書でRustを使う理由、開発環境、結果保存と可視化、本書の進め方を確認しました。 計算機の基本モデルでは、 CPU、メモリ、cache、stack、heap といった計算機の基本モデルを確認しました。 本章では、それをRustの数値計算コードとして書くための最小セットを整理します。

本章はRust Bookを置き換えるものではありません。対象を、計算物理のコードで繰り返し使う要素に絞ります。

  • f64usize、浮動小数点数
  • 複素数と num-complex
  • 関数、trait、letmutiffor
  • Vec<f64>、配列、slice
  • 所有権、借用、可変借用
  • structResult
  • 小さい単体テスト
  • src/lib.rssrc/main.rs、module境界

AI agent を使う場合でも、これらの境界を理解しておくことは重要です。 agent に実装を任せる場合でも、関数の入力と出力、どのデータを所有するのか、 どこを可変にするのか、どの条件をテストするのかは、人間が確認する必要があります。

本章の構成

  • 数値型・関数・小さいテスト f64usize、関数、trait、制御構造、cargo testによる小さい検証を扱います。

  • 浮動小数点演算と誤差 丸め誤差、桁落ち、情報落ち、浮動小数点数の比較、複素数、 Kahan summation などを扱います。

  • Vecとスライス 1次元の数値データをVec<f64>で持ち、関数境界では&[f64]&mut [f64]を使う方法を確認します。

  • Resultとmodule境界 入力不正、配列サイズ不一致、収束失敗をResultで扱い、計算本体、入出力、実行用binaryを分ける方針を説明します。

本章を終えると、以降の章で出てくる小さな数値計算関数を読み、テストし、AI agent に安全に修正を任せるための基礎が整います。

数値型・関数・小さいテスト

数値計算では、まず小さな関数を正しく書き、それを手計算できる入力で検証することが重要です。本節では、以降の章で繰り返し使う最小限のRustの形を確認します。

なぜ小さく分けてテストするか

数値計算プログラムは、1回だけ数値を出せば正しい、というものではありません。 間違いを見つけ、原因を局所化し、修正できる形にしておく必要があります。

これは車を組み立てる作業に似ています。 車にはエンジン、ブレーキ、車輪、配線など多くの部品があります。 すべてを組み上げてから最後に走行テストだけを行うと、 不具合があることは分かっても、どの部品が原因かはすぐには分かりません。 実際には、部品ごとに検査し、いくつかの部品を組み合わせた段階でも検査し、 最後に全体を検査します。

ソフトウェアでも同じです。 小さい関数に分けて単体テストを書けば、計算の部品を個別に検査できます。 他の言語へ移植する場合も、プログラム全体を一度に移すのではなく、 部品ごとに移植し、同じ入力と期待値で正しさを確認できます。

テストを最初から導入することには、もう1つ重要な効果があります。 テストしやすい形にするためには、入力と出力が明確な関数に分ける必要があります。 つまり、早い段階でテストを書くと、自然に部品の境界が分かれます。 逆に、長いmain関数や隠れたグローバル状態に依存する形で書いた後から テストを足すのは困難です。

数値型

本書では、特に断りがない限り実数にはf64を使います。配列の添字や要素数にはusizeを使います。

let dt: f64 = 0.01;
let n_steps: usize = 1000;
let t = n_steps as f64 * dt;

浮動小数点数は、数学的な実数そのものではなく、有限のビット数で実数を近似した値です。 丸め誤差や比較の注意点については、 次節「浮動小数点演算と誤差」で扱います。

整数と浮動小数点数は自動では混ざりません。必要な場所で明示的に変換します。これは冗長に見えることもありますが、数値計算では、添字なのか物理量なのかを読み分けやすくする効果があります。

関数として切り出す

数値計算コードは、最初から大きなmain関数に書くのではなく、小さい関数に分けます。例えば、自由落下の位置を計算する関数は次のように書けます。

fn height(t: f64, h0: f64, v0: f64, g: f64) -> f64 {
    h0 + v0 * t - 0.5 * g * t * t
}

この関数は、入力と出力が明確です。ファイル入出力、プロット、コマンドライン引数とは独立しているため、単体テストしやすくなります。

traitの最小限の見方

trait(トレイト) は、ある型が「何をできるか」を表す約束です。 本書では、最初から複雑なジェネリクスを書く必要はありません。 まずは f64Vec<f64> のような具体的な型で関数を書き、必要になったときに trait を読みます。

数値計算でよく見る trait には、次のようなものがあります。

  • Debug: {:?} で表示できる。
  • Copy: 代入や関数呼び出しで値をコピーできる。
  • Add, Sub, Mul, Div: +, -, *, / の演算に対応する。
  • 外部crateのtrait: そのcrateが提供するメソッドを型に追加する。

例えば、後の章で出てくる ndarray-linalg では、 NormSolve のような trait を use することで、 配列にノルム計算や連立方程式を解くメソッドが見えるようになります。 このような use は、単に名前を短くするだけでなく、 trait が提供するメソッドを使うために必要な場合があります。

標準ライブラリには、f32f64 をまとめる Float trait はありません。 この教材では、特に理由がなければ f64 で書きます。 複数の浮動小数点型に対応する必要が出てきた場合だけ、num-traits のような 外部crateを検討します。

次の例は、trait bound を使った小さい関数です。 このような書き方は、関数を複数の数値型で使いたいときにだけ検討します。

use std::ops::{Add, Mul};

fn squared_norm2<T>(x: T, y: T) -> T
where
    T: Copy + Add<Output = T> + Mul<Output = T>,
{
    x * x + y * y
}

ここで T: Copy + Add<Output = T> + Mul<Output = T> は、 T がコピーでき、足し算と掛け算の結果も同じ T になることを要求しています。 ただし、最初からすべてを generic にする必要はありません。 物理量や配列の型が決まっている場合は、具体的な f64 の関数の方が読みやすいことも多いです。

小さいテスト

浮動小数点数では、==による厳密比較を避け、許容誤差を使って比較します。

fn height(t: f64, h0: f64, v0: f64, g: f64) -> f64 {
    h0 + v0 * t - 0.5 * g * t * t
}

fn close(a: f64, b: f64, tol: f64) -> bool {
    (a - b).abs() < tol
}

#[test]
fn height_at_initial_time_is_initial_height() {
    let h = height(0.0, 100.0, 0.0, 9.8);
    assert!(close(h, 100.0, 1e-12));
}

テストは、まず手計算できる小さい入力から始めます。解析解、保存量、極限ケース、対称性などがある場合は、それをテストに使います。

実行と検査

テストは次のコマンドで実行します。

cargo test

AI agent に実装を任せる場合も、少なくとも次の確認を行います。

cargo check
cargo test
git diff
git diff HEAD

cargo checkcargo testは、型、借用、単体テストを検査します。 git diffはまだstageしていない変更を表示し、 git diff HEADは最終commitから現在の作業ツリーまでの変更全体を表示します。 ただし、コンパイルが通ることは、物理モデルや数値計算法が正しいことを意味しません。 人間は、問題設定、単位、境界条件、検証方法を確認します。

Last change: , commit: cd5425f

浮動小数点演算と誤差

計算物理学では、連続的な物理量をコンピュータ上で離散的に扱います。その際、実数を表現するために浮動小数点数が用いられますが、この表現は有限のビット数で行われるため、本質的に近似であり、誤差の発生は避けられません。

本節では、この浮動小数点数に起因する誤差について解説し、それが数値計算の結果に与える影響と、その対策について学びます。誤差の存在を正しく理解し、適切に対処することは、信頼性の高いシミュレーションを行う上で極めて重要です。

Rustにおける浮動小数点数

Rustの標準的な浮動小数点数型には、f32f64の2種類があります。 どちらも数学的な実数そのものではなく、有限のビット数で実数を近似した値です。

意味メモリ目安の有効桁数最大有限値
f32単精度浮動小数点数32ビット約6桁3.4e38
f64倍精度浮動小数点数64ビット約15桁1.8e308

f32f64よりメモリ使用量が少ないため、大きな配列ではメモリ使用量や メモリ帯域の面で有利になることがあります。ただし、常に高速とは限りません。 速度は計算内容、ハードウェア、メモリアクセスのパターンに依存します。

Important

本書ではf64を標準とします

計算物理学の多くのシミュレーションでは、計算過程で発生する微小な誤差が積み重なり、最終的な結果に大きな影響を与えることがあります。そのため、本書では特に断りがない限り、より高い精度を持つf64型を標準として使用します。

複素数

Fourier 変換や量子力学では、複素数を使います。 Rustの標準ライブラリには複素数型が含まれていないため、 本書では外部クレート num-complex を使います。

Cargo.toml に以下を追加します。

[dependencies]
num-complex = "0.4"

num-complex では、実部と虚部が f64 の複素数型として Complex64 を使えます。

use num_complex::Complex64;

fn main() {
    let z = Complex64::new(1.0, 2.0); // 1 + 2i
    let w = Complex64::new(0.5, -1.0);

    println!("z + w = {}", z + w);
    println!("|z| = {}", z.norm());
}

複素数でも、実部と虚部は浮動小数点数です。 そのため、丸め誤差や比較の注意点は f64 と同じように現れます。 複素数の詳しい使い方は、Fourier 解析や量子力学の章で扱います。

浮動小数点数に起因する誤差

浮動小数点数を用いる際に発生する代表的な誤差を3つ紹介します。

1. 丸め誤差 (Rounding Error)

コンピュータが2進法で数値を扱うことに起因する誤差です。10進法では有限桁で表現できる小数(例: 0.1)が、2進法では無限小数となり、有限のビット数で正確に表現できない場合があります。その結果、最も近い表現可能な値に「丸め」られることで生じるのが丸め誤差です。

この影響を確認するために、簡単なコードを実行してみましょう。

fn main() {
    let a: f64 = 0.1;
    let b: f64 = 0.2;
    let sum = a + b;
    let expected: f64 = 0.3;

    println!("0.1 + 0.2 = {}", sum);
    println!("期待値     = {}", expected);
    println!("両者は等しいか? => {}", sum == expected);

    // 内部的な表現を確認
    println!("実際の値 (内部表現): {:.20}", sum);
}

このコードを実行すると、以下のような結果が得られます。

0.1 + 0.2 = 0.30000000000000004
期待値     = 0.3
両者は等しいか? => false
実際の値 (内部表現): 0.30000000000000004441

0.1 + 0.2 の計算結果が、期待する 0.3 とは微妙に異なる値になっていることがわかります。これは、0.10.2 が2進数で正確に表現できず、それぞれが近似値として格納されているために発生します。

Warning

浮動小数点数の直接比較は避ける

丸め誤差のため、== 演算子による浮動小数点数の直接比較は予期せぬ結果を招く可能性があります。計算結果が特定の値と等しくなることを期待するようなロジックは避けるべきです。比較の方法については後述します。

2. 桁落ち (Cancellation Error)

桁落ちとは、値がほぼ等しい2つの数値の差を計算した際に、有効桁数が大幅に失われてしまう現象です。

例えば、二次方程式 の解の公式を考えます。

ここで、 の場合、 となります。もし であれば、一方の解 の分子において、 がほぼ等しい値となり、桁落ちが発生します。

以下のコードは、 の解をナイーブな解の公式で計算する例です。

fn main() {
    let a: f64 = 1.0;
    let b: f64 = 1.0e8; // 1億 (大きな値にすることで桁落ちが顕著になる)
    let c: f64 = 1.0;

    let d = (b * b - 4.0 * a * c).sqrt();

    // -b と d は非常に近い値になる (絶対値が近い)
    println!("-b = {}", -b);
    println!(" d = {}", d);

    // 桁落ちが発生する計算
    let x1_naive = (-b + d) / (2.0 * a);

    // もう一方の解
    let x2 = (-b - d) / (2.0 * a);

    println!("\nナイーブな計算:");
    println!("x1 = {:.17}", x1_naive);
    println!("x2 = {:.17}", x2);

    // 桁落ちを回避する工夫
    // 解と係数の関係 x1 * x2 = c/a を利用する
    let x1_stable = c / (a * x2);

    println!("\n安定な計算:");
    println!("x1 = {:.17}", x1_stable);

    // 相対誤差を計算
    let relative_error = (x1_naive - x1_stable).abs() / x1_stable.abs();
    println!(
        "\n相対誤差: {:.2e} ({:.1}%)",
        relative_error,
        relative_error * 100.0
    );
}

実行結果:

-b = -100000000
 d = 99999999.99999999

ナイーブな計算:
x1 = -0.00000000745058060
x2 = -100000000.00000000000000000

安定な計算:
x1 = -0.00000001000000000

相対誤差: 2.55e-1 (25.5%)

ナイーブな計算で得られた x1 は、本来の値(約 )と比較して精度が大幅に低下しています。一方、解と係数の関係 を用いて と計算することで、桁落ちを回避し、より精度の高い解を得ることができました。

Note

なぜ x2 は桁落ちしないのか

x2 = (-b - d) / (2a) において、 のとき は負、 は正であるため、 は「負の値からさらに正の値を引く」計算となり、絶対値が増加する方向の演算です。桁落ちは「ほぼ等しい値の減算」で発生するため、x2 の計算では問題が生じません。このように、減算を回避する数式の変形が桁落ち対策の基本となります。

このように、計算順序やアルゴリズムを工夫することで、桁落ちを回避できる場合があります。

3. 情報落ち (Loss of Significance)

絶対値が非常に大きい数値と非常に小さい数値を足し算する際に、小さい数値が結果に反映されず、情報が失われてしまう現象を情報落ちと呼びます。

fn main() {
    let large_number: f64 = 1.0e16;
    let small_number: f64 = 1.0;

    let result = large_number + small_number - large_number;

    println!("(1.0e16 + 1.0) - 1.0e16 = {}", result);
    println!("期待値 = 1.0");
}

このプログラムの実行結果は 0.0 となります。1.0e16 + 1.0 の計算において、1.0f64 が表現できる有効桁数の範囲外となり、無視されてしまったためです。

多数の数値の和を計算する場合、絶対値の小さいものから順に足し合わせることで、情報落ちの影響を軽減できることがあります。より洗練された手法として、後述する Kahan summation(補正加算) があります。

特殊値:NaNとInfinity

浮動小数点数には、通常の数値以外に特殊な値が存在します。これらはシミュレーション中に意図せず発生することがあり、その挙動を理解しておくことが重要です。

Infinity(無限大)

f64::INFINITY(正の無限大)と f64::NEG_INFINITY(負の無限大)は、表現可能な範囲を超えた値を示します。

  • オーバーフロー(非常に大きな数の演算)で発生
  • 正の数をゼロで除算した場合に発生

NaN(Not a Number)

f64::NAN は、数学的に定義できない演算結果を示します。

  • 0.0 / 0.0(ゼロ除算の不定形)
  • (-1.0_f64).sqrt()(負の数の平方根)
  • f64::INFINITY - f64::INFINITY(無限大同士の減算)
fn main() {
    // Infinityの発生
    println!("1.0 / 0.0 = {}", 1.0_f64 / 0.0);           // inf
    println!("1.0e308 * 10.0 = {}", 1.0e308_f64 * 10.0); // inf (オーバーフロー)

    // NaNの発生
    println!("0.0 / 0.0 = {}", 0.0_f64 / 0.0);           // NaN
    println!("(-1.0).sqrt() = {}", (-1.0_f64).sqrt());   // NaN

    // NaNの特殊な性質
    let nan = f64::NAN;
    println!("\nNaN == NaN は {}", nan == nan);  // false(重要!)
    println!("NaN.is_nan() = {}", nan.is_nan()); // true

    // 有限性のチェック
    let inf = f64::INFINITY;
    println!("\n1.0.is_finite() = {}", 1.0_f64.is_finite());   // true
    println!("inf.is_finite() = {}", inf.is_finite());         // false
    println!("nan.is_finite() = {}", nan.is_finite());         // false
}

Warning

NaNの伝播に注意

NaNを含む演算の結果はすべてNaNになります。シミュレーション中に一度でもNaNが発生すると、以降のすべての計算結果が「汚染」されてしまいます。

let nan = f64::NAN;
println!("{}", nan + 1.0);   // NaN
println!("{}", nan * 0.0);   // NaN
println!("{}", nan.sin());   // NaN

結果が期待と異なる場合や、シミュレーションが発散した場合は、is_nan()is_finite() を用いて中間値をチェックすることが有効なデバッグ手法です。

補足: max() / min() とNaN

f64::max() および f64::min() は、引数の一方がNaNの場合、非NaNの値を返します。これはIEEE 754-2008の仕様に準拠した動作であり、NaNは伝播しません。

let nan = f64::NAN;
println!("{}", nan.max(0.0)); // 0(NaNではない!)
println!("{}", nan.min(0.0)); // 0(NaNではない!)

NaNを伝播させたい場合は、f64::maximum() / f64::minimum()(nightly版で利用可能)を使用するか、明示的にNaNをチェックする必要があります。

Note

オーバーフローとアンダーフロー

f64 が表現できる最大値(約 )を超えるとInfinityになります。一方、最小の正規化数(約 )より小さい正の値は、ゼロまたは精度が低下した「非正規化数」になります(アンダーフロー)。

非常に大きな値や小さな値を扱う計算では、対数スケールでの計算や、適切なスケーリング(無次元化)を検討してください。

誤差への対策

これらの誤差が避けられないものである以上、それらを念頭に置いたプログラミングが求められます。

浮動小数点数の比較

前述の通り、== を用いた直接比較は危険です。代わりに、2つの数値の差がごく小さい値(許容誤差イプシロンとも呼ばれる)未満であるかどうかで判定するのが一般的です。

絶対誤差による比較

最も単純な方法は、2つの数値の差の絶対値が許容誤差未満かを確認する方法です。

fn main() {
    let a: f64 = 0.1 + 0.2;
    let b: f64 = 0.3;

    let tolerance = 1e-10; // 許容誤差

    // 絶対誤差による比較
    if (a - b).abs() < tolerance {
        println!("絶対誤差の範囲で a と b は等しい");
    } else {
        println!("絶対誤差の範囲で a と b は等しくない");
    }
}

相対誤差による比較

絶対誤差による比較は、比較する数値が非常に大きい場合や非常に小さい場合にうまく機能しないことがあります。例えば、1.0e101.0e10 + 1.0 を比較する場合、その差 1.01e-10 よりもはるかに大きいですが、値のスケールから考えれば両者は「ほぼ等しい」と見なしたいかもしれません。

このような場合には、相対誤差を用いるのが有効です。

fn relative_eq(a: f64, b: f64, tolerance: f64) -> bool {
    // 注意: NaNが渡された場合、この関数はfalseを返す
    // (NaNとの比較はすべてfalseになるため)

    // ゼロ除算を避ける
    if a == 0.0 || b == 0.0 {
        return (a - b).abs() < tolerance;
    }
    (a - b).abs() / a.abs().max(b.abs()) < tolerance
}

この方法では、差を値の大きさで正規化するため、スケールに依存しない比較が可能になります。

Note

マシンイプシロン f64::EPSILON

Rustの標準ライブラリには、f64::EPSILON という定数が用意されています。これは「1.0と、1.0より大きい次の表現可能なf64の値との差」と定義されており、マシンイプシロンと呼ばれます。 ただし、この値は非常に小さく(約 )、比較対象の数値のスケールによっては実用的でない場合があります。通常は、問題の性質に応じて適切な許容誤差 tolerance を独自に設定する必要があります。

実践的な比較: approx クレート

多くの場合、上記のような比較関数を自前で実装するよりも、approx のような実績のあるクレートを利用するのが最善です。このクレートは、絶対誤差、相対誤差、およびULP(Unit in the Last Place)に基づいた、頑健で使いやすい比較関数を提供します。

Cargo.toml に以下を追加して approx を導入できます。

[dependencies]
approx = "0.5"

そして、assert_relative_eq! マクロなどを使ってテストを書くことができます。

use approx::assert_relative_eq;

fn main() {
    let a: f64 = 0.1 + 0.2;
    let b: f64 = 0.3;

    // a と b が相対誤差の範囲で等しいことを表明する
    assert_relative_eq!(a, b, max_relative = 1e-10);

    println!("approxクレートを使って a と b がほぼ等しいことを確認しました。");
}

シミュレーションコード内の条件分岐などで比較を行いたい場合は、relative_eq!マクロやabs_diff_eq!マクロがブール値を返すため便利です。

本書では、テストコードなどで浮動小数点数の比較を行う際に approx クレートを積極的に利用します。

補正加算(Kahan Summation)

多数の数値を足し合わせる際、単純に加算を繰り返すと情報落ちによる誤差が蓄積します。Kahan summation(補正加算、カハンの加算アルゴリズム)は、誤差を追跡・補正しながら加算を行うことで、この問題を軽減するアルゴリズムです。

/// Kahan summationによる総和計算
fn kahan_sum(values: &[f64]) -> f64 {
    let mut sum = 0.0;
    let mut compensation = 0.0; // 累積誤差の補正項

    for &value in values {
        let y = value - compensation;  // 補正を適用した値
        let t = sum + y;               // 一時的な合計
        compensation = (t - sum) - y;  // 丸め誤差を記録
        sum = t;
    }
    sum
}

fn main() {
    // 小さな値を大量に足し合わせる例
    let n = 1_000_000;
    let small_value = 0.1;
    let values: Vec<f64> = vec![small_value; n];

    // 単純な加算
    let naive_sum: f64 = values.iter().sum();

    // Kahan summation
    let kahan = kahan_sum(&values);

    // 理論値
    let expected = small_value * n as f64;

    println!("理論値:           {:.17}", expected);
    println!("単純な加算:       {:.17}", naive_sum);
    println!("Kahan summation: {:.17}", kahan);
    println!("\n単純な加算の誤差: {:.2e}", (naive_sum - expected).abs());
    println!("Kahanの誤差:      {:.2e}", (kahan - expected).abs());
}

実行結果(環境により若干異なる場合があります):

理論値:           100000.00000000000000000
単純な加算:       100000.00000133288267534
Kahan summation: 100000.00000000000000000

単純な加算の誤差: 1.33e-6
Kahanの誤差:      0.00e0

Kahan summation は、数値積分やモンテカルロ法など、多数の値を累積する計算で特に有効です。本書の後の章でも、精度が重要な場面でこのアルゴリズムを活用します。

数値的に安定なアルゴリズムの選択

同じ問題を解くアルゴリズムでも、計算手順によって誤差の蓄積度合いが異なる場合があります。桁落ちの例で見たように、数式を適切に変形したり、より数値的に安定したアルゴリズムを選択したりすることが重要です。

高精度計算の利用

f64 の精度でも不十分な場合は、より多くのビットを使って数値を表現する「多倍長浮動小数点数」ライブラリを利用するという選択肢もあります。

これについては、付録E「高精度計算」で詳しく解説します。

まとめ

本節では、計算物理学における基本でありながら、非常に重要な浮動小数点数の誤差について学びました。

  • 丸め誤差・桁落ち・情報落ちといった誤差は、浮動小数点数演算において本質的に避けられない。
  • NaNやInfinityといった特殊値の発生に注意し、必要に応じて is_nan()is_finite() でチェックする。
  • == による浮動小数点数の直接比較は危険であり、許容誤差を用いた比較が必要である。
  • Kahan summation などの手法を用いることで、多数の値の加算における誤差を軽減できる。
  • 計算順序の工夫や、数値的に安定なアルゴリズムの選択によって、誤差の影響を軽減できる。

シミュレーションを行う際は、常にこれらの誤差が結果に与える影響を意識することが、科学的に信頼できる結論を導くための第一歩となります。

Last change: , commit: 057d579

Vecとスライス

計算物理では、時系列データ、粒子の状態、格子上の場など、多数の数値をまとめて扱います。1次元の数値データでは、まずVec<f64>とsliceを使えることが重要です。

所有するデータとしてのVec<f64>

Vec<f64>は、実行時に長さが決まる数値列を所有します。例えば、時刻ごとのエネルギーを保存する場合は次のように使います。

let mut energy: Vec<f64> = Vec::with_capacity(1000);

for step in 0..1000 {
    let e = step as f64 * 0.01;
    energy.push(e);
}

要素数の上限が分かっている場合は、Vec::with_capacityで容量を確保しておくと、途中の再割り当てを減らせます。

関数境界としての&[f64]

関数に数値列を渡すときは、多くの場合Vec<f64>そのものではなく&[f64]を受け取ります。

fn mean(xs: &[f64]) -> Option<f64> {
    if xs.is_empty() {
        return None;
    }

    let sum: f64 = xs.iter().sum();
    Some(sum / xs.len() as f64)
}

&[f64]はデータを所有しません。配列、Vec<f64>、配列の一部などを同じ関数に渡せます。

let xs = vec![1.0, 2.0, 3.0];
let avg = mean(&xs);
let first_two = mean(&xs[0..2]);

更新を伴う関数境界としての&mut [f64]

データをその場で更新する関数では、&mut [f64]を使います。

fn scale(xs: &mut [f64], factor: f64) {
    for x in xs {
        *x *= factor;
    }
}

let mut xs = vec![1.0, 2.0, 3.0];
scale(&mut xs, 0.5);

可変にする範囲を関数境界で明示することで、どのデータが変更されうるかを読み取りやすくなります。これはAI agent が生成した差分をレビューするときにも役立ちます。

stack、heap、所有権

小さな固定長配列、例えば3次元ベクトル[f64; 3]は値そのものとして扱いやすいデータです。一方、Vec<f64>は、長さが実行時に決まるデータをheap上に持ちます。

数値計算コードでは、次の使い分けを基本にします。

  • 小さく固定長の量: [f64; 2], [f64; 3]
  • 実行時に長さが決まる1次元データ: Vec<f64>
  • 関数の入力: &[f64]
  • 関数内で更新する入力: &mut [f64]

所有権や借用は、最初は複雑に見えるかもしれません。しかし、数値計算では「どの関数がデータを所有するか」「どの関数が読むだけか」「どの関数が更新するか」をはっきりさせる仕組みとして有用です。

Last change: , commit: 23d4376

Resultとmodule境界

数値計算コードでは、計算そのものだけでなく、入力不正、配列サイズ不一致、収束失敗、ファイル出力失敗なども扱う必要があります。小さい例ではpanic!でも動きますが、再利用する関数ではResultで失敗を返す方が安全です。

Resultで失敗を返す

例えば、2つのベクトルの内積では、長さが一致している必要があります。

fn dot(a: &[f64], b: &[f64]) -> Result<f64, String> {
    if a.len() != b.len() {
        return Err(format!(
            "length mismatch: a has {}, b has {}",
            a.len(),
            b.len()
        ));
    }

    let value = a.iter().zip(b).map(|(x, y)| x * y).sum();
    Ok(value)
}

呼び出し側では、失敗した場合に何をするかを明示できます。

let a = vec![1.0, 2.0, 3.0];
let b = vec![4.0, 5.0, 6.0];
let value = dot(&a, &b)?;

structでパラメータをまとめる

引数が増えてきたら、パラメータや状態をstructにまとめます。

struct EulerConfig {
    dt: f64,
    n_steps: usize,
}

impl EulerConfig {
    fn validate(&self) -> Result<(), String> {
        if self.dt <= 0.0 {
            return Err("dt must be positive".to_string());
        }
        if self.n_steps == 0 {
            return Err("n_steps must be positive".to_string());
        }
        Ok(())
    }
}

入力条件をvalidateに分けると、テストしやすくなります。

module境界

大きい計算では、main.rsにすべてを書かないようにします。例えば、次のように分けます。

src/
├── lib.rs
├── model.rs
├── algorithm.rs
├── io.rs
└── main.rs

役割は次のように分けます。

  • model: 物理モデル、パラメータ、状態量
  • algorithm: 時間発展、反復法、更新規則
  • io: 結果保存、metadata保存
  • main: コマンドライン引数、実行順序
  • tests: 小さい検証問題、解析解との比較

これは、前節で述べた「小さい部品を先に検査する」考え方を、 project全体に広げたものです。 modelalgorithmioを分けておくと、各部品を単体テストしやすくなります。 他の言語へ移植する場合も、moduleごとに移し、同じ小さい検証問題で比較できます。

この分割は、AI agent に実装を任せる場合にも有効です。「algorithmだけを修正してください」「ioにCSV保存を追加してください」「modelの物理量更新を疑似コード化してください」のように、作業範囲を限定できます。

panic!してよい場合

panic!は、プログラムのバグを示す場合や、サンプルコードを短くする場合には使えます。一方、ユーザー入力、ファイル入出力、収束失敗、配列サイズ不一致のように実行時に起こりうる失敗は、Resultで返す方が適しています。

本書では、説明を短くするためにexpectpanic!を使うこともあります。ただし、研究用・演習用のコードを長く育てる場合は、どの失敗を呼び出し側に返すべきかを意識してください。

Last change: , commit: a64e817

多次元データと配列

Important

本章の前提知識

本章の内容を理解するためには、以下の章を事前に学習しておく必要があります。

本章では、1次元の数値データから、多次元配列、memory layout、外部クレート、 データ保存へ進みます。以降の線形代数、偏微分方程式、統計力学、量子力学では、 多次元データを正しく扱うことが重要になります。

本章の目的は、ndarrayを使うことだけではありません。 計算機の基本モデルで見た cache line、連続アクセス、stride の考え方を使い、 配列のshape、axis、copy/view、保存形式、metadataを意識して、 計算結果を後から検証できる形で扱うことを目指します。

本章の構成

本章を終えることで、読者は多次元の数値データをRustで扱い、保存し、後続の解析や可視化につなげるための基礎を習得できます。

1次元データから多次元データへ

Note

本節のポイント

  • Rust標準の配列 ([T; N])、スライス (&[T])、ベクタ (Vec<T>) を多次元データの入口として整理する。
  • 1次元の連続データから、ベクトル、行列、格子データへ進む考え方を確認する。
  • 次節で扱うmemory layoutやndarrayの必要性を理解する。

Rustのこれらコレクション型に習熟している方は、本節を読み飛ばして次節の「多次元配列とメモリレイアウト」に進んでも構いません。

物理シミュレーションやデータ解析では、個々の数値を扱うだけでなく、多数の数値をひとまとめにしてベクトルや行列として扱う場面が頻繁に現れます。例えば、3次元空間における粒子の位置や速度、あるいは観測データの時系列などがそれに当たります。

Rustの標準ライブラリは、このようなデータの集合を扱うための基本的なツールを提供しています。本節では、その中でも特に重要な配列 ([T; N])スライス (&[T])、そしてベクタ (Vec<T>) の3つを取り上げ、その特徴と基本的な使い方、そして数値計算における使い分けについて解説します。

これらの基本的なデータ構造を理解することは、より高度な科学技術計算ライブラリ(例えば後の節で学ぶndarray)を効果的に利用するための土台となります。

配列 [T; N]

配列は、最も基本的なコレクション型です。[T; N]という型で表され、Tは要素の型、Nは要素数を意味します。

特徴

  • 固定長: 配列の長さ(要素数)はコンパイル時に決定され、後から変更することはできません。
  • スタック確保: 配列のデータは、通常スタック上にメモリが確保されます。これにより、ヒープ割り当てのオーバーヘッドがなく、非常に高速にアクセスできます。

数値計算の文脈では、3次元ベクトルや4元数(クォータニオン)のように、コンパイル時に次元数が確定している小さなデータ構造を表現するのに適しています。

基本的な使い方

配列の宣言と初期化は、以下のように行います。

// 3次元の位置ベクトルを表す配列
let position: [f64; 3] = [1.0, 2.0, 3.0];

// すべての要素を0.0で初期化
let velocity: [f64; 3] = [0.0; 3];

// 要素へのアクセス
println!("x-coordinate: {}", position[0]);
println!("Initial velocity: {:?}", velocity);

// forループによるイテレーション
for component in &position {
    println!("{}", component);
}

長所と短所

  • 長所: 高速なアクセス。コンパイル時のサイズチェックによる安全性。
  • 短所: サイズが固定されているため、実行時に要素数を変更したい場合には不向きです。

スライス &[T]

スライスは、それ自体がデータを所有するのではなく、配列やVecといった他のコレクション型の一部または全体を「参照」するための型です。この「参照する」という性質がスライスの最も重要な特徴です。

特徴

  • 所有権を持たない (Borrowing): スライスはデータの所有権を持たず、データを借用(borrow)します。これにより、大きなデータブロックをコピーすることなく、関数にデータの部分集合を効率的に渡すことができます。
  • 動的な長さ: スライスが参照する範囲の長さはコンパイル時には決まっておらず、実行時に決まります。

スライスは、あるデータ集合に対して操作を行う関数を定義する際に、非常に柔軟で効率的なインターフェースを提供します。

基本的な使い方

スライスは、配列やVecから簡単に作成できます。

fn process_data(data: &[f64]) {
    // dataスライス内のすべての要素の平均値を計算する
    let sum: f64 = data.iter().sum();
    let count = data.len();
    if count > 0 {
        println!("Average: {}", sum / count as f64);
    }
}

fn main() {
    let array: [f64; 5] = [1.0, 2.0, 3.0, 4.0, 5.0];
    let vector: Vec<f64> = vec![10.0, 20.0, 30.0];

    // 配列全体をスライスとして渡す
    process_data(&array);

    // 配列の一部をスライスとして渡す
    process_data(&array[1..4]); // インデックス1から3までの要素 ([2.0, 3.0, 4.0])

    // Vec全体をスライスとして渡す
    process_data(&vector);
}

process_data関数は、引数として&[f64]f64のスライス)を取ります。これにより、この関数は配列からもVecからも、その一部からでもデータを受け取ることができます。これは非常に強力な抽象化です。

また、&mut [T]を用いることで、スライスを通じて元のデータを変更することも可能です。

fn scale(data: &mut [f64], factor: f64) {
    for element in data {
        *element *= factor;
    }
}

fn main() {
    let mut values = vec![1.0, 2.0, 3.0];
    scale(&mut values, 2.0);
    println!("{:?}", values); // [2.0, 4.0, 6.0]
}

長所と短所

  • 長所: 配列とVecの両方を同じインターフェースで扱える汎用性。データをコピーせずに部分参照が可能。
  • 短所: 参照先のライフタイムを考慮する必要がある。所有権を持たないため、データを長期間保持する用途には使えない。

ベクタ Vec<T>

ベクタは、可変長のコレクション型です。データの実体はヒープ上に確保され、実行時に要素を追加したり削除したりすることができます。

特徴

  • 可変長: pushメソッドで要素を追加したり、popメソッドで末尾の要素を削除したりと、サイズを自由に変更できます。
  • ヒープ確保: データがヒープ上に存在するため、コンパイル時にサイズが不明なデータを扱うことができます。

数値計算では、シミュレーションの各ステップで得られる結果を時系列データとして記録していく場合など、実行時に要素数が動的に変化する状況で非常に役立ちます。

基本的な使い方

// 空のベクタを作成
let mut time_series: Vec<f64> = Vec::new();

// vec!マクロで初期値を指定して作成
let positions = vec![[0.0, 0.0], [1.0, 1.0]];
println!("Initial positions: {:?}", positions);

// あらかじめ容量を確保しておくと再割り当てを回避できる
let results: Vec<f64> = Vec::with_capacity(1000);

// push: 要素を追加
time_series.push(0.0);
time_series.push(0.1);
time_series.push(0.2);

// 要素へのアクセス
println!("Latest data point: {}", time_series[2]);

// forループによるイテレーション
for data_point in &time_series {
    println!("{}", data_point);
}

println!("Number of data points: {}", time_series.len());
println!("Results capacity: {}", results.capacity());

Tip

境界チェックについて

Rustでは、配列やVecへのインデックスアクセス(例: time_series[0])は実行時に境界チェックが行われます。範囲外のインデックスにアクセスしようとすると、プログラムはパニックして停止します。これは、C/C++で起こりうるバッファオーバーフローのような未定義動作を防ぐための安全機構です。

長所と短所

  • 長所: 実行時にサイズを柔軟に変更できる高い柔軟性。
  • 短所: ヒープ割り当てに伴うわずかなパフォーマンス上のオーバーヘッド。要素追加時に容量が不足すると、より大きなメモリ領域を確保してデータをコピーする「再割り当て(reallocation)」が発生する可能性があります。あらかじめ要素数の上限が分かっている場合は、Vec::with_capacityで容量を確保しておくことで再割り当てを回避できます。

まとめと使い分け

これら3つのデータ構造の特性と、数値計算における主な用途をまとめます。

特性配列 [T; N]スライス &[T]Vec<T>
サイズ固定長(コンパイル時)動的長可変長(実行時)
メモリスタック(参照先による)ヒープ
所有権ありなし(借用)あり
主な用途3次元ベクトルなど、サイズがコンパイル時に確定しているデータ。データ集合を所有権を移さずに扱う関数インターフェース。シミュレーション結果の記録など、実行時にサイズが変わるデータ。

これらの使い分けを理解することは、効率的で読みやすいコードを書くための第一歩です。

  • 配列 [T; N]: 扱うデータの次元や要素数が初めから分かっている場合に最適です。
  • Vec<T>: データの数が実行中に変わる可能性がある場合に選択します。
  • スライス &[T]: 関数にデータの集合を渡す際の引数の型として、ほとんどの場合に最良の選択です。これにより、関数がより汎用的になり、配列とVecの両方で再利用できます。

次のステップへ

本節では、Rust標準ライブラリが提供する基本的なデータ構造について学びました。これらを用いることで、基本的なベクトル演算やデータ管理は可能です。

しかし、より複雑な科学技術計算、例えば行列、格子、場のデータを扱うには、これらのプリミティブな型だけでは表現が冗長になりやすく、shapeやaxisの管理も難しくなります。

そこで次節では、多次元の添字がメモリ上でどのように並ぶかを確認します。その上で、Rustの科学技術計算エコシステムにおける中核的なクレートであるndarrayへ進みます。

Last change: , commit: 23d4376

多次元配列とメモリレイアウト

格子、行列、画像、波動関数、相関関数などを扱うとき、データは多次元の添字を持ちます。 しかし、計算機の基本モデルで見たように、 コンピュータのメモリ上ではデータは基本的に1次元に並びます。 この対応を理解しておくと、配列の扱い、性能、ファイル保存を考えやすくなります。

flattening

2次元配列u[i, j]を1次元のVec<f64>に保存する場合、例えば行優先(row-major)では次のように対応させます。

fn idx(i: usize, j: usize, nx: usize) -> usize {
    i * nx + j
}

このとき、iは行、jは列、nxは1行あたりの要素数です。

let ny = 4;
let nx = 5;
let mut u = vec![0.0; ny * nx];

let i = 2;
let j = 3;
u[idx(i, j, nx)] = 1.0;

flattening を明示すると、メモリ上の並び、境界条件、ファイル出力の対応を確認しやすくなります。

row-major と column-major

多次元配列の並べ方には、主に次の2つがあります。

  • row-major: 同じ行の要素が連続する。C/C++、ndarray、NumPyの標準に近い。
  • column-major: 同じ列の要素が連続する。Fortran、MATLAB、Julia、LAPACK/BLAS、Eigen3でよく使われる。

どちらが正しいという話ではありません。重要なのは、配列を作る側、計算する側、保存する側、読む側で同じ約束を使うことです。

row-major と column-major の違い

ここでは、nynx 列の配列を考えます。 i を行index、j を列indexとすると、row-major では典型的に次の対応になります。

index(i, j) = i * nx + j

このとき、j を1つ増やすとメモリ上でも隣の要素へ進みます。 i を1つ増やすと nx 要素分だけ進みます。

column-major では、同じ形の配列を次のように対応させます。

index(i, j) = j * ny + i

このとき、i を1つ増やすとメモリ上でも隣の要素へ進みます。 j を1つ増やすと ny 要素分だけ進みます。

stride

stride は、ある軸に沿って添字を1つ進めたとき、メモリ上で何要素進むかを表します。 連続した配列ではアクセスが速く、stride が大きいアクセスでは cache line に載ったデータを十分に使えないことがあります。

nynx 列の2次元配列では、典型的には次のようになります。

row-major:

  • j 方向の stride: 1
  • i 方向の stride: nx

column-major:

  • i 方向の stride: 1
  • j 方向の stride: ny

例えば row-major の2次元配列では、同じ行の隣接要素を読むループは連続アクセスになります。

for i in 0..ny {
    for j in 0..nx {
        let value = u[idx(i, j, nx)];
        // valueを使う
    }
}

計算量だけでなく、メモリアクセスの順序も性能に影響します。 大きな配列を扱う章では、loop order、cache、memory bandwidth を意識します。

逆に、row-major の配列を列方向に読むと、nx 要素ずつ飛ぶアクセスになります。

for j in 0..nx {
    for i in 0..ny {
        let value = u[idx(i, j, nx)];
        // valueを使う
    }
}

小さい配列では差が見えないこともあります。 しかし、大きな配列や何度も繰り返す計算では、この違いが実行時間に効きます。

N次元の場合

多次元配列でも考え方は同じです。 shape を (n0, n1, ..., n_{d-1})、添字を (i0, i1, ..., i_{d-1}) と書くと、index は各添字とstrideの積の和になります。

index = i0 * stride0 + i1 * stride1 + ... + i_{d-1} * stride_{d-1}

row-major では右端の添字が最も速く変わります。

strides = (n1 * n2 * ... * n_{d-1}, ..., n_{d-1}, 1)

column-major では左端の添字が最も速く変わります。

strides = (1, n0, n0 * n1, ..., n0 * n1 * ... * n_{d-2})

この規約は、数学的な shape そのものとは別です。 同じ 2 x 3 の行列でも、row-major と column-major では 1次元bufferへの並び方が変わります。 ndarray のような配列型では、shape に加えて stride などのmetadataを持つことで、 同じbufferをさまざまな見方で扱えます。

view、copy、reshape

多次元配列ライブラリでは、部分配列や転置を「view」として扱える場合があります。view は元データへの参照であり、データをコピーしません。一方、連続した新しい配列が必要な場合は、実データのコピーが発生します。

AI agent に配列操作を任せる場合は、次の点を確認してください。

  • view で十分な場所で不要な copy をしていないか。
  • reshape 後の shape とデータ順序が期待通りか。
  • transpose 後に、後続の処理が想定する memory layout と合っているか。
  • 保存時に shape、axis、単位が metadata として残っているか。

多次元配列のバグは、コンパイルは通っても、軸の取り違えや境界条件の間違いとして現れます。小さい配列で手計算できるテストを作ることが重要です。

Last change: , commit: 4ee94dd

外部クレートの活用(ndarray入門)

Note

本節のポイント

  • Rustの科学技術計算における標準的で軽量な配列クレートndarrayの基本を学ぶ。
  • ndarrayを用いて、ベクトルや行列といった多次元配列を効率的に作成・操作する方法を習得する。
  • ndarray-linalgが、LAPACK系の線形代数をndarrayに接続するcrateであることを確認する。

本節は、前節「1次元データから多次元データへ」と「多次元配列とメモリレイアウト」で学んだ内容を土台としています。

前の節までで、Rustの標準ライブラリが提供する配列、スライス、Vec<T>、そして多次元データのmemory layoutを確認しました。これらはデータ管理の基本として有用ですが、より高度な科学技術計算、特に多次元配列(ベクトル、行列、テンソル)を多用する線形代数演算には力不足です。

例えば、Vec<Vec<f64>>で「行列」を表現しようとすると、以下のような課題に直面します。

  • メモリ効率: 各行が独立したヒープ割り当てとなり、データがメモリ上で連続的に配置されないため、キャッシュ効率が悪化します。
  • 演算の煩雑さ: 行列の積や転置といった基本的な演算も、手動でループを実装する必要があり、コードが冗長かつエラーが発生しやすくなります。
  • パフォーマンス: 最適化された線形代数ライブラリ(BLASなど)の恩恵を受けにくく、手実装のループでは高いパフォーマンスを得るのが困難です。

このような課題を解決する基本的な選択肢が、ndarray クレートです。 ndarrayは、RustでN次元配列、array view、多次元slicing、効率的な演算を扱うための 標準的で軽量なcrateです。

さらに、固有値分解、特異値分解、線形方程式の求解などの既存LAPACK系の線形代数を ndarray と接続したい場合は、ndarray-linalg が候補になります。 これは ndarrayArrayBase に線形代数機能を提供し、 外部のLAPACK実装を利用するcrateです。

本節では、まずndarrayの基本的な使い方を学び、 必要に応じてndarray-linalgへ進む位置づけを確認します。

ndarrayのセットアップ

まず、ndarrayをプロジェクトに追加します。Cargo.tomlファイルの[dependencies]セクションに以下の行を追記してください。

[dependencies]
ndarray = "0.17"

バージョンは執筆時点のものです。最新版はcrates.ioで確認できます。

ndarray-linalgを使う場合は、LAPACK backendも選ぶ必要があります。 OpenBLAS、Netlib、Intel MKLなどのbackend featureから1つを選ぶ形です。 教材では詳細なbackend構築手順には踏み込みません。 必要になった時点で、公式documentationを確認して、使用環境に合うbackendを選びます。

本書のコード例では、個別の型、関数、traitをuse宣言で明示的にインポートします。 これにより、どの機能がどのcrateから来ているかを追いやすくします。

use ndarray::{arr1, arr2, Array, Array1, Array2, Axis};

1次元配列 (Array1):ベクトルの表現

ndarrayにおける1次元配列Array1<T>は、数学的なベクトルに対応します。

ベクトルの作成

ndarrayには、ベクトルを生成するための多様な方法が用意されています。

use ndarray::{arr1, Array, Array1};

fn main() {
    // スライスからベクトルを作成
    let a: Array1<f64> = Array::from(vec![1.0, 2.0, 3.0]);
    println!("a = {}", a);

    // arr1 関数を使った、より簡潔な作成方法
    let b = arr1(&[4.0, 5.0, 6.0]);
    println!("b = {}", b);

    // 0から9までの連番を持つベクトルを作成
    let c: Array1<f64> = Array::range(0.0, 10.0, 1.0);
    println!("c = {}", c);

    // すべての要素が0.0のベクトルを作成(次元を指定)
    let zeros = Array1::<f64>::zeros(5);
    println!("zeros = {}", zeros);
}

ベクトル演算

ndarrayの最大の利点の一つは、ベクトルや行列に対する数学的な演算が直感的に書ける点です。+, -, *, / といった演算子がオーバーロードされており、要素ごとの演算が可能です。

use ndarray::arr1;

fn main() {
    let a = arr1(&[1.0, 2.0, 3.0]);
    let b = arr1(&[4.0, 5.0, 6.0]);

    // ベクトルの加算
    println!("a + b = {}", &a + &b);

    // ベクトルの減算
    println!("a - b = {}", &a - &b);

    // スカラー倍
    println!("a * 2.0 = {}", &a * 2.0);

    // 要素ごとの積
    println!("a * b (element-wise) = {}", &a * &b);

    // 内積 (dot product)
    let dot_product = a.dot(&b);
    println!("a . b = {}", dot_product);
}

Note

参照と所有権 &a + &bのように、演算時にベクトルの参照(&)を渡している点に注目してください。ndarrayの多くの二項演算は参照を受け取るように実装されています。これにより、演算後も元のデータabの所有権が維持され、続けて使用することができます。もしa + bと記述すると、aの所有権が移動してしまい、以降aは使えなくなります。

2次元配列 (Array2):行列の表現

ndarrayにおける2次元配列Array2<T>は、行列に対応します。

行列の作成

行列もベクトルと同様、様々な方法で作成できます。 ndarray のowned arrayは、標準では row-major、つまり同じ行の隣接要素が メモリ上で連続する配置です。これはC-orderとも呼ばれます。 Array2::from_shape_vec((rows, cols), data) に渡す data も、この順序で解釈されます。

use ndarray::{arr2, Array2};

fn main() {
    // arr2 関数で行列を作成
    // 各内部配列が「行」に対応する
    let m = arr2(&[[1.0, 2.0, 3.0],
                   [4.0, 5.0, 6.0]]);
    println!("m =\n{}", m);

    // from_shape_vecで行列を作成
    // (行数, 列数)のタプルと、データを格納したVecを渡す
    let shape = (3, 2);
    let data = vec![1, 2, 3, 4, 5, 6];
    let m_from_vec = Array2::from_shape_vec(shape, data)
        .expect("Incompatible shape");
    println!("m_from_vec =\n{}", m_from_vec);

    // 3x3のゼロ行列
    let zeros = Array2::<f64>::zeros((3, 3));
    println!("zeros =\n{}", zeros);

    // 2x2の単位行列
    let eye = Array2::<f64>::eye(2);
    println!("eye =\n{}", eye);
}

Note

layoutとstride

ndarray では、配列がshapeとstrideを持ちます。 owned arrayを普通に作る場合は row-major と考えてよいですが、 slice、transpose、view ではstrideが変わります。 性能や外部ライブラリとの接続が重要な場合は、shapeだけでなくstrideも確認します。

行列演算

行列同士の加減算やスカラー倍もベクトルと同様に直感的に記述できます。そして、dotメソッドを用いることで、行列積や行列とベクトルの積を計算できます。

use ndarray::{arr1, arr2};

fn main() {
    let m1 = arr2(&[[1.0, 2.0],
                    [3.0, 4.0]]);

    let m2 = arr2(&[[5.0, 6.0],
                    [7.0, 8.0]]);

    // 行列の加算
    println!("m1 + m2 =\n{}", &m1 + &m2);

    // 行列のスカラー倍
    println!("m1 * 3.0 =\n{}", &m1 * 3.0);

    // 行列積
    let matrix_product = m1.dot(&m2);
    println!("m1 * m2 (matrix product) =\n{}", matrix_product);

    // 行列とベクトルの積
    let v = arr1(&[10.0, 20.0]);
    let matrix_vector_product = m1.dot(&v);
    println!("m1 * v =\n{}", matrix_vector_product);
}

ndarray 自体でも、基本的な行列積や要素ごとの演算を扱えます。 一方、分解、固有値、特異値分解、線形方程式の求解など、 LAPACK系の機能が必要な場合は ndarray-linalg を使います。 ndarray-linalg はbackend featureと外部libraryの設定に依存するため、 小さい演習で不用意に必須化しません。

スライシング

ndarrayの特に強力な機能がスライシングです。これにより、配列の一部を効率的に、かつ柔軟に抜き出すことができます。スライシングはNumPyのそれに非常によく似ており、s![]マクロを用いて行います。

use ndarray::{arr2, s};

fn main() {
    let m = arr2(&[[ 1,  2,  3,  4],
                   [ 5,  6,  7,  8],
                   [ 9, 10, 11, 12]]);
    println!("m =\n{}", m);

    // 0番目の行を取得
    let row0 = m.slice(s![0, ..]);
    println!("Row 0: {}", row0); // [1, 2, 3, 4]

    // 1番目の列を取得
    let col1 = m.slice(s![.., 1]);
    println!("Column 1: {}", col1); // [2, 6, 10]

    // 部分行列を取得
    // 0-1行目、1-2列目を抜き出す
    let sub_matrix = m.slice(s![0..2, 1..3]);
    println!("Sub-matrix (0..2, 1..3) =\n{}", sub_matrix);
    // [[2, 3],
    //  [6, 7]]

    // スライスは元のデータのビュー(参照)
    // スライスを介して元のデータを変更することも可能
    let mut m_mut = m.clone();
    let mut sub_view = m_mut.slice_mut(s![0..2, 1..3]);
    sub_view.fill(0); // 部分行列を0で埋める
    println!("Modified m_mut =\n{}", m_mut);
}

Important

スライスはビューである

slice()メソッドが返すArrayView型は、元の配列データの所有権を持たない「ビュー(参照)」です。これにより、巨大な行列の一部を操作する際に、不要なデータコピーを回避し、高いメモリ効率とパフォーマンスを実現します。

ブロードキャスト

ブロードキャストは、形状(shape)が異なる配列間の演算を可能にする強力な機能です。ndarrayは、不足している次元を自動的に「引き伸ばし」て、形状を一致させてから演算を実行します。

例えば、行列のすべての要素に同じ値を加算する、あるいは行列の各行に同じベクトルを加算するといった操作が簡単に行えます。

use ndarray::{arr1, arr2, Axis};

fn main() {
    let m = arr2(&[[1.0, 2.0],
                   [3.0, 4.0]]);

    let v = arr1(&[10.0, 20.0]);

    // 行列の各行にベクトルvを加算する
    // vは (2,) -> (1, 2) にブロードキャストされ、
    // それがさらに (2, 2) に引き伸ばされてmと加算される
    let result_row = &m + &v;
    println!("m + v (row-wise broadcast) =\n{}", result_row);

    // 行列の各列にベクトルを加算する場合
    // ベクトルを列ベクトルとして扱うために次元を追加する必要がある
    // v.view().insert_axis(Axis(1)) は vの形状を (2,) から (2, 1) に変える
    // view() を使うことで v の所有権を保持したままビューを操作できる
    let result_col = &m + &v.view().insert_axis(Axis(1));
    println!("m + v (column-wise broadcast) =\n{}", result_col);
}

実行結果:

m + v (row-wise broadcast) =
[[11, 22],
 [13, 24]]
m + v (column-wise broadcast) =
[[11, 12],
 [23, 24]]

ndarrayとRust標準型の連携

ndarrayは標準ライブラリの型とスムーズに連携できます。

  • ArrayからVecへ: to_vec()
  • Arrayからスライスへ: as_slice()
  • スライスからArrayViewへ: ArrayView::from()

これにより、既存のRustコードにndarrayを段階的に導入したり、ndarrayで計算した結果を他のライブラリに渡したりすることが容易になります。

その他の高度な機能

ndarrayは本節で紹介した以外にも、以下のような多数の機能を提供します。

  • 高階関数: map, fold, zipなど、配列を効率的に操作するメソッド。
  • 軸に沿った操作: sum, mean, max, minなどを特定の軸(行方向や列方向)に沿って計算。
  • LAPACK系の線形代数: ndarray-linalgクレートと組み合わせることで、分解、固有値問題、特異値分解(SVD)、線形方程式の求解などを扱えます。

まとめ

本節では、Rustの科学技術計算でよく使われる軽量な配列クレートndarrayの基本的な使い方を学びました。

  • Array1(ベクトル)とArray2(行列)の作成方法と、それらの間の基本的な演算(四則演算、内積、行列積)を習得しました。
  • s![]マクロを用いた柔軟なスライシング機能により、配列の一部を効率的に参照・操作できることを見ました。
  • ブロードキャスト機能により、形状の異なる配列間でも直感的な演算が可能であることを学びました。

ndarrayは、Rustで多次元配列を扱う標準的な軽量crateです。 LAPACK系の線形代数が必要な場合は ndarray-linalg も確認します。

参照:

Last change: , commit: d65f323

発展的なテンソルライブラリ tenferro

Note

本節のポイント

  • まずは Vec<f64>&[f64]ndarray を基本にする。
  • tenferro は、より高機能なテンソル計算が必要になったときの発展的な選択肢である。
  • tenferro は本書の著者の1人が開発に関わるライブラリである。
  • tenferro は単一のクレートではなく、tenferro-tensortenferro-ad などの 複数クレートからなる workspace で、crates.io に公開されている。
  • tenferro の密なテンソルは column-major(列優先)なので、ndarray の標準的な row-major(行優先)と混同しない。
  • tenferro はまだ 0.x 版(pre-1.0)なので、使う場合は公式リポジトリとドキュメントで 現在のAPIを確認する。

1次元の所有データには、通常 Vec<f64> を使います。 関数に渡すときは、&[f64]&mut [f64] を使うのが基本です。

2次元以上の配列を軽く扱うだけなら、ndarray が自然な選択肢です。 本書の多くの例でも、まず ndarray を使います。

tenferro は、それより発展的なテンソル計算のためのライブラリです。 ここでいうテンソルは、ベクトルや行列をさらに多次元にした数値配列だと思えば十分です。 単に2次元配列を持つためだけなら、tenferro は大きすぎる選択になりがちです。

本書で tenferro を紹介する理由の1つは、本書の著者の1人が開発に関わっているためです。 Rustで科学技術計算を発展させるときに、どのような機能が必要になり、 どこに実装上の注意があるかを考える具体例として扱います。

どのような場合に使うか

tenferro は、次のような機能を同じ枠組みで扱いたい場合に候補になります。

  • 自動微分: 計算式から、微分や勾配を自動的に計算する機能です。 最適化問題や逆問題で使うことがあります。
  • GPU実行: CPUではなくGPU上で大きなテンソル演算を実行します。 ただし、CPUとGPUの間でデータを移す時間も考える必要があります。
  • テンソル縮約: 添字を持つ多次元配列について、ある添字に関して和を取る演算です。 行列積もテンソル縮約の一例です。 Python/NumPy では einsum という名前でよく使われます。
  • 線形代数やFFTとの接続: 行列分解、連立方程式、フーリエ変換などをテンソル計算の流れの中で扱います。

このような機能が必要でなければ、まず ndarrayndarray-linalg で十分かを考えます。

クレート構成

tenferro は単一の tenferro クレートではありません。 tenferro-tensortenferro-adtenferro-einsumtenferro-linalgtenferro-ffttenferro-cputenferro-runtime などの複数クレートからなる workspace で、それぞれが crates.io に公開されています。 ルートの facade クレートは意図的に用意されておらず、 必要な層や演算を担うクレートを個別に use する設計です。

よく使うユーザ向けクレートは次のあたりです。

  • tenferro-tensor: 実行時のテンソル値と backend trait。
  • tenferro-cpu: CPU backend。
  • tenferro-runtime: concrete/traced tensor helper、graph のコンパイルと実行。
  • tenferro-ad: eager/traced の自動微分 API。
  • tenferro-einsum: einsum 縮約。
  • tenferro-linalg: 行列分解などの線形代数。
  • tenferro-fft: FFT。

internal を名前に含むクレートは実装用で、ユーザ向けのAPI面ではありません。 本書では現在のAPIを暗記することが目的ではないため、 実際に使う場合は公式ドキュメントで公開クレートと import path を確認します。

メモリレイアウト

tenferro の密なテンソルは column-major(列優先)です。 つまり、左端の添字がメモリ上で最も速く変わります。

例えば、論理的な 2 x 3 の行列を

[[1, 2, 3],
 [4, 5, 6]]

と書くと、column-major の1次元バッファは次の順序になります。

[1, 4, 2, 5, 3, 6]

これは ndarray の標準的な row-major(行優先)とは違います。 ndarray やNumPyの標準的な並びからtenferroへデータを渡す場合は、 どちらの順序のバッファなのかを必ず確認します。

tenferro のテンソル生成には from_vec_col_major のように 並び順が名前に入った constructor が用意されています。 自分が渡したバッファが column-major であることを確認したうえで使います。

AI agent に実装を任せる場合も、入力データが row-major なのか column-major なのか、 各軸が何を意味するのかを明示します。 この確認を省くと、shape は合っていても、物理的には転置されたデータを計算してしまうことがあります。

使う前に確認すること

tenferro は crates.io で公開されていますが、まだ 0.x 版(pre-1.0)です。 version 間で breaking change がありうるので、 本書では現在のAPIを暗記することは目的にしません。 実際に使う場合は、公式リポジトリとドキュメントを確認してから、 小さい検証用プロジェクトで試します。

AI agent に tenferro を使ったコードを書かせる場合は、少なくとも次を確認します。

  • 参照した公式ドキュメントまたはリポジトリ。
  • Cargo.toml に書いたクレート名(tenferro-tensor などの個別クレート名)。
  • どの名前を use したか。
  • Cargo.lock に記録された version(crates.io 依存の場合)。 Git 依存を使った場合はその revision。
  • CPUで実行するのか、GPUで実行するのか。
  • CPUとGPUの間でデータを移す場所。
  • 入出力バッファの並び順。
  • 各軸の意味。
  • 小さい入力で期待値を手計算できるテスト。

Cargo.lock には、実際に解決されたクレートの version が記録されます。 0.x 版のクレートは version 間でAPIが変わりうるため、 後から同じコードで再実行できるかどうかは version 固定に依存します。 そのため、演習プロジェクトでも Cargo.lock を残します。

最小例

本節の例は tenferro 0.2 で動作を確認しています。 API は 0.x の間に変わりうるので、手元で動かす場合は 公式ドキュメントで現在のシグニチャを確認してください。 以下の依存を Cargo.toml に書きます。

[dependencies]
tenferro-tensor = "0.2"
tenferro-cpu = "0.2"
tenferro-runtime = "0.2"
tenferro-einsum = "0.2"
tenferro-ad = "0.2"
tenferro-linalg = "0.2"

einsum

ij,jk->ik は行列積と同じ縮約です。 入力は column-major で渡し、結果の shape が [2, 4] になることを確認します。

use tenferro_cpu::CpuBackend;
use tenferro_einsum::TensorEinsumExt;
use tenferro_tensor::Tensor;

let a = Tensor::from_vec_col_major(vec![2, 3], vec![1.0_f64; 6])?;
let b = Tensor::from_vec_col_major(vec![3, 4], vec![1.0_f64; 12])?;
let mut backend = CpuBackend::new();

let out = [&a, &b].einsum("ij,jk->ik", &mut backend)?;
assert_eq!(out.shape(), &[2, 4]);

自動微分

AdContext で勾配を計算します。 ここでは x * xx に関する勾配を取り、結果がスカラー(rank 0)になることを確認します。

use tenferro_ad::AdContext;
use tenferro_runtime::TracedTensor;

let ad = AdContext::builder().build()?;
let x = TracedTensor::from_vec_col_major(vec![], vec![3.0_f64])?;
let loss = (&x * &x)?;
let dx = ad.grad(&loss, &x)?;
assert_eq!(dx.rank, 0);

線形代数

tenferro-linalg は traced な行列分解を提供します。 Cholesky 分解を graph にコンパイルし、CPU backend で実行します。 linalg は CPU で BLAS/LAPACK 系の provider クレートを引き込むことがあるので、 手元で feature と外部 library の要件を確認してください。

use tenferro_cpu::CpuBackend;
use tenferro_linalg::TracedTensorLinalgExt;
use tenferro_runtime::{GraphCompiler, GraphExecutor, TracedTensor};

let a = TracedTensor::from_vec_col_major(vec![2, 2], vec![4.0_f64, 2.0, 2.0, 3.0])?;
let l = a.cholesky()?;

let mut compiler = GraphCompiler::new();
let program = compiler.compile(&l)?;

let mut executor = GraphExecutor::new(CpuBackend::new());
executor.register_extension(tenferro_linalg::register_runtime)?;
let out = executor.run(&program)?;
assert_eq!(out.shape(), &[2, 2]);

本章での位置づけ

本章では、まず標準的で軽量な ndarray を確認します。 LAPACK系の線形代数が必要なら ndarray-linalg を確認します。 そのうえで、自動微分、GPU実行、テンソル縮約のような機能が必要になった場合の 発展的な選択肢として tenferro を紹介します。

参照:

Last change: , commit: 5d57fa8

データ保存とmetadata

計算結果は、後から確認できる形で保存します。画面への出力だけでは、条件を変えて比較したり、同じデータから図を作り直したり、他の言語で解析したりすることが難しくなります。

表形式データ

時系列や少数列の結果は、CSVで保存すると扱いやすいです。

t,energy,magnetization
0.0,-1.24,0.83
0.1,-1.25,0.82

CSVはgnuplot、Python、Julia、表計算ソフトなどから読みやすいため、最初の保存形式として適しています。

多次元配列

格子上の場、波動関数、画像、相関関数などの多次元配列では、CSVはあまり適していません。形状、軸、データ型を別に管理する必要があり、ファイルサイズも大きくなりやすいためです。

このような場合は、次の形式を検討します。

  • HDF5
  • NumPy形式(.npy, .npz
  • 必要に応じた独自binary形式

HDF5やNumPy形式は、多くの言語やツールから読み書きしやすい形式です。Rust側で計算し、保存したデータをPythonやJuliaで解析する流れにも向いています。

metadata

数値データだけでなく、実行条件も保存します。例えば、次のような情報です。

  • 入力パラメータ
  • 格子サイズ、shape、axisの意味
  • 時間刻み、空間刻み
  • 境界条件
  • 乱数seed
  • crate version、Rust version
  • 出力ファイル名
  • 実行日時

metadata は、JSON、TOML、YAMLなどのテキスト形式で保存すると確認しやすいです。

{
  "model": "ising_2d",
  "lattice_size": [64, 64],
  "temperature": 2.269,
  "seed": 12345,
  "output": "samples.npy"
}

compute と plot を分ける

本書では、計算、保存、可視化を分けます。

  1. Rustで計算する。
  2. 結果とmetadataを保存する。
  3. 保存済みデータから図を作る。
  4. 図と数値を使って検証する。

この流れにすると、AI agent に入出力コードやプロット用スクリプトを任せやすくなります。ただし、保存する列、axis、単位、境界条件、検証対象が正しいかは人間が確認します。

Last change: , commit: 23d4376

作業用ディレクトリのセットアップ

第2部以降の演習では、教材リポジトリの外に作業用ディレクトリを作ります。 教材リポジトリは読む場所、作業用ディレクトリは実装、テスト、結果保存を行う場所として分けます。

mkdir -p ~/rust-computational-physics-work
cd ~/rust-computational-physics-work

標準ディレクトリ構成

作業用ディレクトリの下には、章やテーマごとにsub directoryを作ります。 各leaf directoryは、原則として独立したCargo projectにします。

rust-computational-physics-work/
  AGENTS.md
  CLAUDE.md
  README.md
  calculus/
    integration/
      Cargo.toml
      src/
        lib.rs
        main.rs
      tests/
        integration_test.rs
      output/
  ode/
    euler-rk4/
  monte-carlo/
    integration/

トップレベルの指示ファイル

AI coding agentを使う場合は、作業用ディレクトリのtopにAGENTS.mdを置くことを推奨します。 Claude Codeを使う場合は、必要に応じてCLAUDE.mdも置き、AGENTS.mdを参照させます。

AGENTS.md の例:

# AGENTS.md

このディレクトリは rust-computational-physics-tutorial の演習用作業場所です。

- 教材リポジトリ本体は編集しない。
- 各テーマは独立したCargo projectとしてsub directoryに作る。
- 数値計算の本体は `src/lib.rs` に置く。
- 実行用コード、CSV出力、パラメータ実験は `src/main.rs` に置く。
- 小さい手計算可能なケースを `tests/` に追加する。
- `cargo test` を通してから結果を信用する。
- 生成されたCSVや画像は `output/` に置く。

CLAUDE.md を置く場合は、次のように AGENTS.md を参照するだけで構いません。

@AGENTS.md

テーマごとのCargo project

各テーマでは、次の形を標準にします。

mkdir -p ~/rust-computational-physics-work/calculus/integration
cd ~/rust-computational-physics-work/calculus/integration
cargo init --bin
mkdir -p tests output
  • src/lib.rs: 数値計算の本体。小さい関数に分け、テストしやすくする。
  • src/main.rs: 実行条件の設定、CSV出力、簡単なパラメータ走査を担当する。
  • tests/: 手計算可能な入力、既知解、保存量、再現性を確認する。
  • output/: CSV、metadata、図などの生成物を置く。

本文のサンプルコードも、この構成を意識して読みます。つまり、計算本体は src/lib.rs に置ける関数として定義し、fn main() はその関数を呼び出して 結果を表示したり、CSVへ保存したりする薄い実行例に留めます。

演習の標準形

第2部と第3部の演習では、各テーマに対して次の2種類を基本にします。

  1. ユニットテストの追加 本文で示した関数に対して、cargo testで確認するテストを追加します。 どのケースをテストすべきかはAI coding agentと相談して構いませんが、 解析解、既知解、小さい格子、固定seed、境界条件、許容誤差を必ず確認します。
  2. コードの拡張 収束表のCSV出力、保存量の記録、metadataの保存、別手法との比較などを追加します。

AI coding agentに依頼する場合も、まず関数分割、入力、出力、検証方法を確認させてから実装に進みます。

数値微分と数値積分

Important

本章の前提知識

本章の内容を理解するためには、以下の章を事前に学習しておく必要があります。

第1部では、計算機の基本モデル、Rustで数値計算を行うための最小限の書き方、 多次元データ、結果保存の方針を確認しました。本章では、その知識を活用して、 微積分学の基本的な演算である「微分」と「積分」をコンピュータ上で実現する手法を学びます。

微分と積分は、物理学における運動方程式の解析、エネルギー計算、確率分布の取り扱いなど、計算物理学のあらゆる場面で必要不可欠な操作です。しかし、解析的に計算できる場合は限られており、多くの実問題では数値的な手法に頼らざるを得ません。本章では、そうした数値計算手法の原理と実装を、Rustを用いて体系的に学んでいきます。

本章の構成

  • 数値微分 関数の微分をコンピュータ上で近似的に求める「数値微分」の手法を解説します。前進差分、後退差分、中心差分などの基本的な差分法と、それらの精度や誤差の特性について学びます。

  • 数値積分(台形則・シンプソン則) 定積分を数値的に計算するための基本的な手法である「台形則」と「シンプソン則」を紹介します。これらは関数を区間ごとに多項式で近似し、その面積を計算することで積分値を求める古典的かつ実用的な手法です。

  • ガウス求積法 台形則やシンプソン則よりも高精度な積分を実現する「ガウス求積法」について解説します。この手法は、積分点の配置を最適化することで、少ない評価点数で高い精度を達成できる強力な手法です。

  • 適応型積分 被積分関数の振る舞いに応じて自動的に積分点の配置を調整する「適応型積分」を学びます。この手法により、関数が急激に変化する領域でも効率的かつ高精度に積分を計算できるようになります。

本章を終えることで、読者は微分・積分を数値的に計算する様々な手法を習得し、 それらをRustで実装する能力を身につけます。これらの技術は、後続章で扱う 微分方程式の数値解法や、より高度な物理シミュレーションの基盤となります。

作業テーマ

作業ディレクトリは ~/rust-computational-physics-work/ からの相対パスです。 ユニットテストで確認する具体的なケースは、解析解、境界条件、許容誤差の観点からAI coding agentと相談して決めます。

テーマ作業ディレクトリ構造化するコードユニットテスト演習拡張演習
数値微分calculus/differentiationforward_diff, central_diff, estimate_errorsin(x) や低次多項式で解析解と比較する刻み幅ごとの誤差をCSVに出力する
数値積分calculus/integrationtrapezoidal_rule, simpson_rule, estimate_error低次多項式を解析解と比較する分割数ごとの誤差をCSVに出力する
ガウス求積法calculus/gaussian-quadraturegauss_legendre_2, map_interval3次以下の多項式で厳密性を確認する分点数を切り替えられる形にする
適応型積分calculus/adaptive-integrationadaptive_simpson, estimate_local_error滑らかな関数で許容誤差を確認する再帰回数と区間分割をmetadataとして保存する

検証と実装の観点

本章の実装では、公式をコードに写すだけでなく、既知の関数で精度を確認します。 例えば、 の微分、低次多項式の積分、解析解が分かる滑らかな関数を使うと、 差分幅や分割数を変えたときの誤差の変化を追跡できます。

  • 微分では、前進差分・中心差分の誤差が刻み幅にどう依存するかを確認する。
  • 積分では、n = 0、分割数の偶奇、非滑らかな関数などの境界条件を明示する。
  • differentiateintegrateestimate_error のように小さい関数へ分ける。
  • agentに実装を任せた場合も、比較対象、許容誤差、分割数をdiffで確認する。

数値微分

解析的に導関数(微分)を求めることが困難、あるいは不可能な関数に対し、計算機を用いて近似的に微分係数を求める手法を数値微分 (Numerical Differentiation) と呼びます。

物理シミュレーションにおいては、物体の運動方程式 を解く際や、ポテンシャルエネルギー から力 を求める際など、至る所で微分計算が必要となります。

本節では、数値微分の最も基本的な手法である差分法 (Finite Difference Method) について、その原理とRustによる実装、そして数値計算特有の誤差の振る舞いについて解説します。

微分の定義と差分近似

関数 における微分係数 の数学的な定義は以下の通りです。

コンピュータでは無限小の極限 を直接扱うことはできません。その代わり、十分に小さな有限の値 (刻み幅、ステップサイズ)を用いて、微分係数を近似します。これを差分近似と呼びます。

近似の方法によって、いくつかの種類があります。

1. 前進差分 (Forward Difference)

定義式をそのまま用いて、点 とその少し先の点 の値を使う方法です。

2. 後退差分 (Backward Difference)

とその少し手前の点 の値を使う方法です。

3. 中心差分 (Central Difference)

を中心とした前後 の値を使う方法です。

幾何学的には、前進差分と後退差分が「2点間の直線の傾き」で接線の傾きを近似するのに対し、中心差分は「前後2点の中点における傾き」を用いていることになります。一般に、中心差分の方が前進・後退差分よりも高精度になります(理由は後述します)。

Rustによる実装

これらの差分法をRustで実装してみましょう。ここでは、関数 を例にとり、 における微分係数を計算します。解析解は なので、これと比較して精度を確認します。

fn forward_difference<F>(f: F, x: f64, h: f64) -> f64
where
    F: Fn(f64) -> f64,
{
    (f(x + h) - f(x)) / h
}

fn backward_difference<F>(f: F, x: f64, h: f64) -> f64
where
    F: Fn(f64) -> f64,
{
    (f(x) - f(x - h)) / h
}

fn central_difference<F>(f: F, x: f64, h: f64) -> f64
where
    F: Fn(f64) -> f64,
{
    (f(x + h) - f(x - h)) / (2.0 * h)
}

fn main() {
    // 微分対象の関数 f(x) = sin(x)
    let f = |x: f64| x.sin();

    // 微分を計算する点
    let x: f64 = 1.0;

    // 刻み幅 h
    let h: f64 = 0.01;

    // 解析解 (真値): cos(1.0)
    let exact = x.cos();

    // 1. 前進差分
    let diff_forward = forward_difference(f, x, h);

    // 2. 後退差分
    let diff_backward = backward_difference(f, x, h);

    // 3. 中心差分
    let diff_central = central_difference(f, x, h);

    println!("解析解:    {:.10}", exact);
    println!("前進差分:  {:.10} (誤差: {:.2e})", diff_forward, (diff_forward - exact).abs());
    println!("後退差分:  {:.10} (誤差: {:.2e})", diff_backward, (diff_backward - exact).abs());
    println!("中心差分:  {:.10} (誤差: {:.2e})", diff_central, (diff_central - exact).abs());
}

実行結果の例:

解析解:    0.5403023059
前進差分:  0.5360859810 (誤差: 4.22e-3)
後退差分:  0.5445006207 (誤差: 4.20e-3)
中心差分:  0.5402933009 (誤差: 9.00e-6)

この結果から、同じ刻み幅 でも、中心差分の方が圧倒的に誤差が小さいことがわかります。

誤差の解析:なぜ中心差分が良いのか?

テイラー展開を用いて誤差(打ち切り誤差)を評価することで、この理由を数学的に説明できます。

関数 の周りでテイラー展開すると以下のようになります。

前進差分の誤差

前進差分の式にテイラー展開を代入して整理すると:

つまり、近似値と真値 との差(誤差)は、 の1乗に比例します(オーダー )。

中心差分の誤差

一方、中心差分の式 に代入すると、偶数次の項が相殺されます。

こちらの誤差は、 の2乗に比例します(オーダー )。

が小さい場合(例: )、 となり、 に比べてはるかに小さくなります。これが、中心差分が高精度である理由です。

最適なステップサイズと丸め誤差

を小さくすればするほど精度が良くなる」と考えがちですが、数値計算ではそう単純ではありません。コンピュータの浮動小数点数には丸め誤差が存在するためです。

を極端に小さくすると、分子の において、非常に近い値同士の引き算(桁落ち)が発生し、有効桁数が失われてしまいます。

実際に を変化させながら誤差をプロットしてみると、以下のようになります。

  1. 打ち切り誤差支配領域: が大きい領域。テイラー展開の近似誤差が支配的で、 を小さくすると精度が向上する。
  2. 丸め誤差支配領域: が小さすぎる領域。桁落ちによる丸め誤差が支配的で、 を小さくすると逆に精度が悪化する。

最適な は、これら2つの誤差のバランスが取れる点に存在します。

実験:最適なhを探る

以下のコードで、 から まで変化させたときの誤差を確認してみましょう。

fn central_difference<F>(f: F, x: f64, h: f64) -> f64
where
    F: Fn(f64) -> f64,
{
    (f(x + h) - f(x - h)) / (2.0 * h)
}

fn central_difference_error<F, G>(f: F, df_exact: G, x: f64, h: f64) -> f64
where
    F: Fn(f64) -> f64,
    G: Fn(f64) -> f64,
{
    (central_difference(f, x, h) - df_exact(x)).abs()
}

fn main() {
    let f = |x: f64| x.sin();
    let df_exact = |x: f64| x.cos();
    let x: f64 = 1.0;

    println!("h,      Error (Central)");

    let mut h = 1.0;
    for _ in 0..16 {
        h /= 10.0;
        let error = central_difference_error(f, df_exact, x, h);
        println!("{:.1e}, {:.2e}", h, error);
    }
}

実行結果:

h,      Error (Central)
1.0e-1, 9.00e-4
1.0e-2, 9.00e-6
1.0e-3, 9.01e-8
1.0e-4, 9.00e-10
1.0e-5, 1.11e-11
1.0e-6, 2.77e-11
1.0e-7, 1.94e-10
1.0e-8, 2.97e-9
...
1.0e-16, 1.48e-2

倍精度浮動小数点数(f64)を用いる場合、中心差分における最適な はおおよそ 程度になります(関数のスケールにもよりますが、マシンイプシロン に対して 程度が目安と言われます)。

Note

実用的な数値計算ライブラリでは、このような最適な を自動的に推定するアルゴリズムが組み込まれている場合もあります。

2階微分の数値計算

運動方程式 などで登場する2階微分 も、中心差分法を応用して求めることができます。

この近似式の誤差も です。

fn second_derivative_central<F>(f: F, x: f64, h: f64) -> f64
where
    F: Fn(f64) -> f64,
{
    (f(x + h) - 2.0 * f(x) + f(x - h)) / (h * h)
}

まとめ

  • 数値微分には前進差分後退差分中心差分などの手法がある。
  • 中心差分 の精度を持ち、他の2つ( )より高精度であるため、通常はこれが推奨される。
  • ステップサイズ は小さければ良いわけではなく、丸め誤差(桁落ち) に注意する必要がある。
  • f64 を用いる場合、中心差分での最適な 程度が目安となる。

次節では、数値積分について学びます。

コラム: 自動微分 (Automatic Differentiation) について

本節では「差分法」による数値微分を学びましたが、コンピュータで微分を行う手法は他にもあります。特に近年注目されているのが自動微分 (Automatic Differentiation, AD) です。

差分法 vs 自動微分

  • 差分法 (Finite Difference):

    • 定義通りに微小な幅 での傾きを計算する。
    • 長所: 実装が簡単。関数の中身を知らなくても(ブラックボックスでも)計算可能。
    • 短所: 打ち切り誤差と丸め誤差のジレンマがある(完全な精度は出ない)。
    • 用途: 偏微分方程式を格子状に離散化して解く手法(差分法)など、場のシミュレーションで主役となる。
  • 自動微分 (AD):

    • プログラムの構成要素(足し算、掛け算、sinなど)に対して「連鎖律 (Chain Rule)」を適用し、機械的に導関数を計算する。
    • 長所: 解析解と同じ精度が得られる(誤差は浮動小数点数の丸め誤差のみ)。 の調整が不要。
    • 短所: コンパイラやライブラリの特別なサポートが必要。
    • 用途: 機械学習(バックプロパゲーション)や物理シミュレーションのパラメータ最適化(逆解析)。

Rustと std::autodiff

Rustコミュニティでは、コンパイラレベルで自動微分をサポートする取り組み(std::autodiffの提案や Enzyme プロジェクトとの統合など)が進められています。これが実現すれば、物理シミュレーションのコードを書くだけで、そのパラメータに対する感度解析や最適化が簡単に分かるようになるかも知れません。

本書では、物理シミュレーションの基礎原理(運動方程式をどう離散化するか)を理解するために「差分法」を中心に扱いますが、非線形方程式と最適化などでは、ADの考え方が重要になります。

Last change: , commit: ec78068

数値積分

数値積分(Numerical Integration)は、定積分

の値を数値的に近似計算する手法です。解析的に不定積分を求めることが困難な関数や、離散的なデータ点としてしか与えられていない関数(実験データなど)の積分値を求めるために用いられます。物理学では、運動方程式の積分によるエネルギー計算、電磁場のポテンシャル計算、分配関数の計算など、極めて広範な応用を持ちます。

本節では、最も基本的かつ実用的な手法である台形則 (Trapezoidal Rule)シンプソン則 (Simpson’s Rule) について解説します。これらの手法は「ニュートン・コーツ (Newton-Cotes) の公式」と呼ばれる手法群の一種で、積分区間を等間隔に分割して関数値を評価します。

複合台形則 (Trapezoidal Rule)

原理

台形則は、関数 を各小区間で一次関数(直線)で近似し、その下の面積を台形の面積として計算する手法です。

Trapezoidal Rule

積分区間 等分し、刻み幅を とします。分点を ( ) とすると、各区間 の面積は以下のように近似されます。

これらを全区間にわたって足し合わせることで、全体の積分値の近似式(複合台形則)が得られます。

誤差

台形則の誤差は、刻み幅 の2乗に比例します( )。つまり、分割数 を2倍( を半分)にすると、誤差は約 1/4 に減少します。

複合シンプソン則 (Simpson’s Rule)

原理

シンプソン則は、隣り合う2つの小区間 をまとめて、その区間内の関数を二次関数(放物線)で近似する手法です。これにより、一次近似である台形則よりも高い精度が得られます。

Simpson’s Rule

シンプソン則を適用するには、分割数 が偶数である必要があります。近似式は以下のようになります。

係数が のパターンで並ぶのが特徴です。

誤差

シンプソン則の誤差は、刻み幅 の4乗に比例します( )。つまり、分割数 を2倍にすると、誤差は約 1/16 に激減します。これが、シンプソン則が広く使われる理由です。

補足: それぞれの公式の導出

台形則の導出 区間 において、関数 を通る1次多項式(ラグランジュ補間多項式)で近似します。

これを区間 で積分すると、台形則の公式が得られます。

シンプソン則の導出 区間 において、3点 を通る2次多項式で近似します。

ここで はラグランジュ基底多項式です。これを区間 (幅 )で積分すると、シンプソン則の公式が得られます。

Rustによる実装

それでは、これらの手法をRustで実装し、精度を比較してみましょう。 例として、 を区間 で積分します。解析解は です。

use std::f64::consts::PI;

/// 台形則
fn trapezoidal_rule<F>(f: F, a: f64, b: f64, n: usize) -> f64
where
    F: Fn(f64) -> f64,
{
    let h = (b - a) / n as f64;
    let sum: f64 = (1..n).map(|i| f(a + i as f64 * h)).sum();

    // 両端の点は重み 1/2
    h * (0.5 * f(a) + sum + 0.5 * f(b))
}

/// シンプソン則 (nは偶数でなければならない)
fn simpsons_rule<F>(f: F, a: f64, b: f64, n: usize) -> f64
where
    F: Fn(f64) -> f64,
{
    assert!(n % 2 == 0, "Simpson's rule requires an even number of intervals.");

    let h = (b - a) / n as f64;

    let mut sum_odd = 0.0;
    let mut sum_even = 0.0;

    for i in 1..n {
        let x = a + i as f64 * h;
        if i % 2 == 0 {
            sum_even += f(x);
        } else {
            sum_odd += f(x);
        }
    }

    h / 3.0 * (f(a) + 4.0 * sum_odd + 2.0 * sum_even + f(b))
}

fn main() {
    let f = |x: f64| x.sin();
    let a = 0.0;
    let b = PI;
    let exact = 2.0;

    println!("{:<10} {:<20} {:<20} {:<20} {:<20}",
        "N", "Trapezoidal", "Error (Trap)", "Simpson", "Error (Simp)");
    println!("{}", "-".repeat(95));

    let n_values = [10, 20, 40, 80, 160];

    for &n in &n_values {
        let trap = trapezoidal_rule(f, a, b, n);
        let simp = simpsons_rule(f, a, b, n);

        println!(
            "{:<10} {:.15}    {:.5e}           {:.15}    {:.5e}",
            n, trap, (trap - exact).abs(), simp, (simp - exact).abs());
    }
}

実行結果:

N          Trapezoidal          Error (Trap)         Simpson              Error (Simp)
-----------------------------------------------------------------------------------------------
10         1.983523537509455    1.64765e-2           2.000109517315004    1.09517e-4
20         1.995885972708715    4.11403e-3           2.000006784441801    6.78444e-6
40         1.998971810497066    1.02819e-3           2.000000423093183    4.23093e-7
80         1.999742972445836    2.57028e-4           2.000000026428759    2.64288e-8
160        1.999935744350136    6.42556e-5           2.000000001651570    1.65157e-9

結果の考察

  • 台形則: が2倍(10 -> 20)になると、誤差は約 1/4 ( ) になっています。これは理論通り の収束です。
  • シンプソン則: が2倍(10 -> 20)になると、誤差は約 1/16 ( ) になっています。これも理論通り の収束を示しています。
  • 同じ分割数 で比較すると、シンプソン則の方が圧倒的に高精度であることがわかります。

どちらを使うべきか?

  • 関数が滑らかな場合: シンプソン則が推奨されます。少ない計算コストで高い精度が得られます。
  • 実験データなどノイズを含む場合: 高次の近似がかえってノイズを増幅させる可能性があるため、台形則の方が安定する場合があり、一般的によく用いられます。
  • 周期関数の全周期積分: 周期関数の場合、台形則が例外的に極めて高い精度(指数関数的収束)を示すことが知られています。

まとめ

  • 台形則は一次近似であり、誤差は 。シンプルでロバスト。
  • シンプソン則は二次近似であり、誤差は 。滑らかな関数に対して非常に高精度。
  • シンプソン則を使うには、分割数 が偶数である必要がある。

次節では、より高度な積分手法である「ガウス求積法」について学びます。

Last change: , commit: f424230

ガウス求積法

前節で紹介した台形則やシンプソン則は、積分区間を等間隔に分割する手法(ニュートン・コーツの公式)でした。これに対し、ガウス求積法 (Gaussian Quadrature)、特にガウス・ルジャンドル積分 (Gauss-Legendre Integration) は、積分点(分点)の配置と重みを最適化することで、より少ない点数で極めて高い精度を実現する手法です。

この手法は、被積分関数が滑らかである(高階微分可能である)場合に圧倒的な威力を発揮します。

原理

基本公式

ガウス・ルジャンドル積分では、積分区間を に正規化した上で、以下の近似式を用います。

ここで、 は積分点の数(次数)、 は積分点、 は重み係数です。 ガウス求積法の最大の特徴は、 個の点を用いるだけで、 次以下の任意の多項式を厳密に積分できる という点です。

例えば、 の場合、5次関数までの積分が厳密に一致します。シンプソン則(3点等間隔)が3次関数までしか厳密でないことと比較すると、その効率の良さが分かります。

積分点と重み

積分点 は、ルジャンドル多項式 (Legendre Polynomials) の零点(根)として選ばれます。 また、重み は以下の式で決定されます。

これらは解析的に計算可能であり、多くの数値計算ライブラリではあらかじめ計算された値(テーブル)が用いられます。

例: n=5 の場合の積分点と重み

ix_i (積分点)w_i (重み)
1, 5± 0.9061798459…0.2369268850…
2, 4± 0.5384693101…0.4786286704…
30.0000000000…0.5688888888…

積分点は原点に対して対称に配置され、端点( )よりも内側に配置されるのが特徴です。

積分区間の変換

一般的な積分区間 での積分計算を行うためには、変数変換を行って積分区間を に写像する必要があります。

変数変換:

これにより、公式は以下のように変形されます。

Rustによる実装

ここでは、 の場合のガウス・ルジャンドル積分を実装します。積分点と重みは定数としてハードコードします。 より高い次数が必要な場合は、ルジャンドル多項式の根を数値的に求めるアルゴリズム(ニュートン法など)を実装するか、gauss-quad のような外部クレートを利用するのが一般的です。

/// ガウス・ルジャンドル積分(5点)
///
/// 5点のガウス求積法を用いて、区間 [a, b] で関数 f(x) を積分します。
/// 9次以下の多項式に対して厳密解を与えます。
fn gauss_legendre_5<F>(f: F, a: f64, b: f64) -> f64
where
    F: Fn(f64) -> f64,
{
    // n=5 の積分点と重み
    // 対称性を利用して定義することも可能ですが、ここでは列挙します
    const NODES: [f64; 5] = [
        0.0,
        0.5384693101056831,
        -0.5384693101056831,
        0.9061798459386640,
        -0.9061798459386640,
    ];
    const WEIGHTS: [f64; 5] = [
        0.5688888888888889,
        0.4786286704993665,
        0.4786286704993665,
        0.2369268850561891,
        0.2369268850561891,
    ];

    let mid = 0.5 * (a + b);
    let half_len = 0.5 * (b - a);
    let mut sum = 0.0;

    for i in 0..5 {
        let x = mid + half_len * NODES[i];
        sum += WEIGHTS[i] * f(x);
    }

    sum * half_len
}

fn main() {
    // 積分対象: f(x) = sin(x)
    // 区間: [0, PI]
    // 解析解: 2.0
    use std::f64::consts::PI;
    let f = |x: f64| x.sin();
    let a = 0.0;
    let b = PI;
    let exact = 2.0;

    let result = gauss_legendre_5(f, a, b);

    println!("Gaussian Quadrature (n=5)");
    println!("Result: {:.16}", result);
    println!("Error:  {:.2e}", (result - exact).abs());

    // 比較: シンプソン則 (n=10, 評価点数は11点)
    // ガウス積分(n=5)は評価点数が5点なので、それより多くの点数を使う条件で比較
    println!("\n(参考) Simpson's Rule (n=10)");
    // シンプソン則の実装は省略(前節参照)し、結果のオーダーのみ記述
    // 前節の結果から、n=10のとき誤差は約 6.8e-6 程度
}

実行結果(例):

Gaussian Quadrature (n=5)
Result: 2.0000001102844727
Error:  1.10e-7

(参考) Simpson's Rule (n=10)
Error:  ~ 6.8e-6

たった5回の関数評価で、 という高い精度が得られていることがわかります。シンプソン則で同等の精度を得ようとすると、分割数をかなり増やす必要があります。

長所と短所

長所

  1. 圧倒的な効率: 滑らかな関数に対しては、非常に少ない評価点数で高精度が得られる。
  2. 計算コストの削減: 関数評価( の計算)に時間がかかる場合、評価回数を減らせるメリットが大きい。

短所

  1. 汎用性の低さ: 積分点 と重み が事前に必要であり、等間隔データ(実験データなど)には適用できない。
  2. 特異点への弱さ: 端点に特異点がある場合や、関数が滑らかでない(不連続点や折れ曲がりがある)場合、精度が急激に低下する。
  3. 計算の複雑さ: 任意の に対して積分点と重みを求めるには、特殊関数の計算が必要となる。

発展:分点の自動生成

任意の次数 に対する積分点と重みは、ルジャンドル多項式 の漸化式と、ニュートン法による求根アルゴリズムを組み合わせることで計算できます。

ルジャンドル多項式の漸化式:

また、導関数の関係式:

これらを用いて、 から の間で となる解を探すプログラムを作成すれば、任意の次数のガウス求積法を実装可能です。Rustのエコシステムには gauss-quad などのクレートがあり、これらを利用するのも良い選択肢です。

まとめ

  • ガウス求積法は、積分点を最適化することで、滑らかな関数の積分において最高の効率を誇る。
  • 点で 次の多項式を厳密に積分できる。
  • 実験データではなく、数式として与えられた関数の積分(特に物理シミュレーションにおける相互作用計算など)に適している。

次節では、より柔軟に積分精度を制御できる「適応型積分」について学びます。

Last change: , commit: ec78068

適応型積分

これまでに学んだ台形則、シンプソン則ガウス求積法は、積分区間全体に対して一様な刻み幅(または固定された分点配置)を用いていました。しかし、物理シミュレーションで現れる関数は、区間全体で滑らかなものばかりではありません。

例えば、ある狭い領域でのみ急激に変化したり、激しく振動したりする関数を積分する場合、一様な刻み幅では「変化の激しい部分」に合わせて非常に細かい分割数が必要となり、計算コストが無駄に増大してしまいます。

適応型積分 (Adaptive Integration) は、関数の変化が激しい場所では刻み幅を細かく、緩やかな場所では粗くすることで、所望の精度を最小限の計算コストで達成する手法です。

原理

適応型積分の基本的な戦略は「分割統治法 (Divide and Conquer)」です。

  1. 積分区間 における積分値 を、ある数値積分公式(例:シンプソン則)で計算する。
  2. 区間を半分 (ここで )に分割し、それぞれの区間での積分値の和 を計算する。
  3. の差から誤差を推定する。
  4. 推定された誤差が許容値(トレランス)以下であれば、 (より高精度な方)を解として採用する。
  5. 誤差が許容値を超えている場合は、2つの小区間 それぞれに対して、この手順を再帰的に適用する。

これにより、関数の変化が激しい領域だけが自動的に深く分割され、効率的な積分が可能になります。

適応型シンプソン法 (Adaptive Simpson’s Method)

最も代表的な実装は、シンプソン則を用いたものです。 区間 に対するシンプソン則の値を とします。

ステップ1で計算する値(全区間):

ステップ2で計算する値(2分割):

このとき、シンプソン則の誤差項の性質から、真の積分値 との誤差 は以下のように推定できます。

したがって、停止条件は は許容誤差)となります。 最終的な積分値としては、誤差補正を行った値 (リチャードソン補外に相当)を用いると、さらに精度が向上します( の精度)。

Rustによる実装

適応型シンプソン法を再帰関数を用いて実装します。 テスト関数として、原点付近で急激に振動する や、局所的なピークを持つ関数などを積分してみます。ここでは、単純ですが変化のある のような特異点に近い挙動をする関数ではなく、扱いやすい鋭いピークを持つ関数 を例にします。

fn adaptive_simpson_recursive<F>(
    f: &F,
    a: f64,
    b: f64,
    eps: f64,
    s: f64,
    fa: f64,
    fb: f64,
    fm: f64,
    depth: usize,
) -> f64
where
    F: Fn(f64) -> f64,
{
    let m = 0.5 * (a + b);
    let h = b - a;

    // 左右の分点
    let lm = 0.5 * (a + m);
    let rm = 0.5 * (m + b);

    // 関数評価
    let flm = f(lm);
    let frm = f(rm);

    // 左右の小区間でのシンプソン値
    // S(a, m)
    let s_left = (h * 0.5) / 6.0 * (fa + 4.0 * flm + fm);
    // S(m, b)
    let s_right = (h * 0.5) / 6.0 * (fm + 4.0 * frm + fb);

    let s2 = s_left + s_right;

    // 誤差推定
    let error = (s2 - s).abs() / 15.0;

    // 停止条件: 誤差が許容値以下 または 再帰が深すぎる場合
    if error <= eps || depth == 0 {
        // 誤差補正を含めた値を返す (Richardson extrapolation)
        s2 + (s2 - s) / 15.0
    } else {
        // 再帰的に分割
        // 許容誤差も分割に応じてスケールさせる(半分にする)のが一般的
        let eps_half = eps * 0.5;
        adaptive_simpson_recursive(f, a, m, eps_half, s_left, fa, fm, flm, depth - 1)
            + adaptive_simpson_recursive(f, m, b, eps_half, s_right, fm, fb, frm, depth - 1)
    }
}

/// 適応型シンプソン積分のエントリポイント
fn adaptive_simpson<F>(f: F, a: f64, b: f64, tol: f64) -> f64
where
    F: Fn(f64) -> f64,
{
    let m = 0.5 * (a + b);
    let h = b - a;
    let fa = f(a);
    let fb = f(b);
    let fm = f(m);

    // 全区間でのシンプソン値
    let s = h / 6.0 * (fa + 4.0 * fm + fb);

    // 最大再帰深さ(スタックオーバーフロー防止)
    let max_depth = 50;

    adaptive_simpson_recursive(&f, a, b, tol, s, fa, fb, fm, max_depth)
}

/// 固定刻みの複合シンプソン則
fn composite_simpson<F>(f: F, a: f64, b: f64, n: usize) -> f64
where
    F: Fn(f64) -> f64,
{
    assert!(n % 2 == 0, "Simpson's rule requires an even number of intervals.");

    let h = (b - a) / n as f64;
    let mut sum = f(a) + f(b);

    for i in 1..n {
        let x = a + i as f64 * h;
        sum += if i % 2 == 0 { 2.0 } else { 4.0 } * f(x);
    }

    sum * h / 3.0
}

fn gaussian_peak(x: f64) -> f64 {
    (-100.0 * (x - 0.5).powi(2)).exp()
}

fn gaussian_peak_exact() -> f64 {
    use std::f64::consts::PI;

    // int_0^1 exp(-100(x-0.5)^2) dx = sqrt(pi)/10 * erf(5)
    (PI / 100.0).sqrt() * libm::erf(5.0)
}

fn main() {
    // 積分対象: 鋭いガウスピークを持つ関数
    // x = 0.5 にピーク、幅が狭い
    let a = 0.0;
    let b = 1.0;
    let tolerance = 1e-8;

    // 適応型積分
    let result = adaptive_simpson(gaussian_peak, a, b, tolerance);

    // 比較用:固定刻みシンプソン則 (N=100)
    let fixed_result = composite_simpson(gaussian_peak, a, b, 100);

    // 解析解に近い値(高精度計算の結果)
    let exact = gaussian_peak_exact();

    println!("Target: Gaussian peak at x=0.5");
    println!("Exact:            {:.12}", exact);
    println!("Adaptive Simpson: {:.12} (Error: {:.2e})", result, (result - exact).abs());
    println!("Fixed Simpson:    {:.12} (Error: {:.2e})", fixed_result, (fixed_result - exact).abs());
}

Note

実行には libm クレート(誤差関数 erf のため)が必要ですが、ここでは比較用の真値計算に使っているだけなので、適応型積分のアルゴリズム自体には不要です。

実行結果の例:

Target: Gaussian peak at x=0.5
Exact:            0.177245385090
Adaptive Simpson: 0.177245386384 (Error: 1.29e-9)
Fixed Simpson:    0.177245385090 (Error: 1.36e-15)

この例では、適応型積分が非常に高い精度を達成していることがわかります。固定刻みの場合、ピーク部分の急激な変化を捉えきれずに誤差が残る可能性がありますが、適応型では自動的にピーク周辺が細分化されます。

メリットと注意点

メリット

  • パラメータ調整が不要: 必要な分割数 を事前に知らなくても、許容誤差 tol を指定するだけで結果が得られる。
  • 効率的: 関数が平坦な部分は粗く、激しい部分は細かく計算するため、無駄な計算を省ける。

注意点

  • 再帰呼び出しのコスト: 非常に深い再帰が発生すると、スタックオーバーフローを起こす可能性がある。実用的なライブラリでは、再帰ではなくスタックデータ構造を用いた反復処理で実装されることも多い。
  • 鋭すぎるピークの見逃し: 初期の分割点( )すべてで関数値がほぼ0になるような、極めて狭いピークが区間内に存在する場合、平坦な関数だと誤判定して計算を終了してしまうリスクがある。これを防ぐには、初期分割数をある程度増やしてから適応型アルゴリズムを適用するなどの工夫が必要。

まとめ

本章では、数値積分の主要な手法を学びました。

  • 台形則・シンプソン則: 基本的な手法。等間隔データに適している。
  • ガウス求積法: 滑らかな関数に対して最強の効率を誇る。
  • 適応型積分: 関数の挙動に合わせて刻み幅を自動調整し、汎用性が高い。

これらを用途に応じて使い分けることが、計算物理学における数値積分の鍵となります。

次章からは、これらの知識を基に、より応用的な数値計算手法である「線形代数」の計算について学んでいきます。

Last change: , commit: ec78068

線形代数

Important

この章を読む前に

この章を読むには、以下の章を先に読んでおく必要があります。

本章の概要

物理シミュレーションにおいて、線形代数は最も重要な道具の一つです。偏微分方程式の離散化、量子力学におけるハミルトニアンの対角化、データ解析における主成分分析など、あらゆる場面で行列やベクトルが現れます。

本章では、Rustを用いて線形代数の問題を数値的に解く方法を学びます。特に、多次元データと配列で導入したndarrayクレートを基礎とし、線形代数演算に特化したndarray-linalgクレートなどを活用して、効率的かつ安全なコードを記述することを目指します。

本章で扱うトピック

  1. 行列演算の基礎

    • ノルム、トレース、行列式などの基本量の計算
    • 逆行列と条件数
    • ndarray-linalgの導入
  2. 連立一次方程式

    • の数値解法
    • ガウスの消去法とLU分解
    • コレスキー分解(対称正定値行列の場合)
  3. 固有値問題

    • 固有値と固有ベクトルの計算
    • べき乗法(Power Iteration)の実装
    • エルミート行列の対角化
  4. スパース行列

    • 疎行列(スパース行列)とは
    • CSR/CSC形式
    • sprsクレートを用いた大規模疎行列の計算

準備

本章のサンプルコードを実行するには、Cargo.tomlに以下の依存関係を追加する必要があります(バージョンは執筆時点の目安です)。

[dependencies]
ndarray = "0.17" # またはそれ以降
ndarray-linalg = "0.18" # BLASバックエンドが必要

Warning

ndarray-linalgとBLAS ndarray-linalgを使用するには、システムにBLAS/LAPACKライブラリ(OpenBLAS, Intel MKLなど)がインストールされている必要があります。macOSではAccelerateフレームワークが標準で利用可能ですが、執筆時点ではndarray-linalgが対応していません。 その為、各OSでOpenBLASなどを別途インストールする必要がある場合があります。

作業テーマ

作業ディレクトリは ~/rust-computational-physics-work/ からの相対パスです。 ユニットテストで確認する具体的なケースは、解析解、境界条件、許容誤差の観点からAI coding agentと相談して決めます。

テーマ作業ディレクトリ構造化するコードユニットテスト演習拡張演習
行列演算linear-algebra/matrix-opsmatmul, transpose, norm小さい行列の積とノルムを手計算と比較するloop orderを変えて実行時間を比較する
連立一次方程式linear-algebra/linear-systemssolve, residual_norm, is_square既知解を持つ小さい行列で残差を確認する複数右辺を解く例へ拡張する
固有値問題linear-algebra/eigenvaluepower_iteration, normalize, eigen_residual対角行列や2x2行列で固有値を確認する収束履歴をCSVに出力する
スパース行列linear-algebra/sparsebuild_laplacian_1d, matvec小さい1次元ラプラシアンを手計算と比較するdense表現とのメモリ量を比較する

検証と実装の観点

線形代数のコードは、計算が完了しても正しいとは限りません。既知の小さい行列で 手計算できる例を用意し、さらに大きい問題では残差や条件数を確認します。

  • 行列とベクトルのshapeを最初に確認し、サイズ不一致を曖昧に扱わない。
  • 連立一次方程式では、解そのものだけでなく の残差を確認する。
  • 固有値問題では、正規化と の残差を確認する。
  • ndarray-linalgを使う場合は、BLAS/LAPACK backendとcrate versionを記録する。

行列演算の基礎

前節までにndarrayを用いた多次元配列の作成や、要素ごとの四則演算、行列積(ドット積)について学びました。本節では、より専門的な線形代数演算、例えば行列式、逆行列、ノルムなどの計算方法を扱います。

Rustのndarrayクレート自体は、純粋なRustで書かれた軽量な配列ライブラリであり、高度な線形代数アルゴリズム(固有値分解や特異値分解など)は直接提供していません。これらの機能を利用するには、ndarray-linalg というコンパニオンクレートを使用するのが一般的です。これは、古くから実績のある数値計算ライブラリであるBLAS/LAPACKへのRustバインディングを提供します。

ndarray-linalg の準備

まず、Cargo.tomlndarray-linalgを追加します。また、バックエンドとしてOpenBLASなどを使用するように設定する必要があります。

[dependencies]
ndarray = "0.17"
ndarray-linalg = { version = "0.18", features = ["openblas-system"] } # 環境に合わせて選択

コード内では、トレイトをインポートすることでメソッドが拡張されます。

use ndarray::{arr1, arr2};
use ndarray_linalg::{Determinant, Inverse, Norm, OperationNorm};

ノルム (Norm)

ベクトルや行列の「大きさ」を測る尺度がノルムです。物理シミュレーションでは、解の収束判定や誤差評価に頻繁に使用されます。

ベクトルノルム

ベクトル ノルムは以下のように定義されます。

よく使われるのは ノルム(ユークリッドノルム)と ノルム(最大値ノルム)です。

use ndarray::arr1;
use ndarray_linalg::Norm;

fn main() {
    let x = arr1(&[3.0, 4.0]);

    // L2ノルム: √(3² + 4²) = 5.0
    println!("L2 norm: {}", x.norm_l2());

    // L1ノルム: |3| + |4| = 7.0
    println!("L1 norm: {}", x.norm_l1());

    // 最大値ノルム: max(|3|, |4|) = 4.0
    println!("Max norm: {}", x.norm_max());
}

行列ノルム

行列 では、誘導ノルム(1ノルム、無限大ノルム)や Frobenius ノルムなどを使います。Frobenius ノルムは全要素をまとめて測る便利な行列ノルムですが、ベクトルノルムから誘導される演算子ノルムではありません。

特に、誘導2ノルム(スペクトルノルム)は最大特異値で表せます。

ここで の最大特異値です。実対称行列では固有値の絶対値の最大値と一致しますが、一般の行列では固有値ではなく特異値で評価する点に注意します。

use ndarray::arr2;
use ndarray_linalg::OperationNorm;

fn main() {
    let a = arr2(&[[1.0, 2.0],
                   [3.0, 4.0]]);

    println!("Matrix 1-norm: {}", a.opnorm_one().unwrap());
    println!("Matrix infinity norm: {}", a.opnorm_inf().unwrap());
    println!("Frobenius norm: {}", a.opnorm_fro().unwrap());

    // Frobeniusノルム(全要素の二乗和の平方根)
    // ndarray-linalg では `norm` は L2ノルムを指すことが多いですが、
    // 行列に対してはフロベニウスノルムが一般的です。
    // (注: バージョンやバックエンドによりAPIが異なる場合があります)
}

トレースと行列式

正方行列 に対して、トレース(対角和) と行列式 は基本的な不変量です。

トレース (Trace)

トレースは対角成分の和です。ndarraydiag()メソッドで対角成分を取り出して和をとることで計算できます。

use ndarray::arr2;

fn main() {
    let a = arr2(&[[1.0, 2.0],
                   [3.0, 4.0]]);

    println!("Trace: {}", a.diag().sum()); // 1.0 + 4.0 = 5.0
}

行列式 (Determinant)

行列式は ndarray-linalgdet() メソッドで計算できます。

use ndarray::arr2;
use ndarray_linalg::Determinant;

fn main() {
    let a = arr2(&[[1.0, 2.0],
                   [3.0, 4.0]]);

    // det(A) = 1*4 - 2*3 = -2
    println!("Determinant: {}", a.det().unwrap());
}

Note

det()Result 型を返します。計算過程(内部的なLU分解など)でエラーが発生する可能性があるためです。

逆行列 (Inverse Matrix)

正則な行列 に対して、 を計算します。

数値計算の観点からは、連立一次方程式 を解くために逆行列を明示的に求めて と計算することは推奨されません。主な理由は数値安定性です。特に係数行列 の条件数

が大きい(ill-conditioned、悪条件)場合、入力データや丸め誤差の小さなずれが解に大きく増幅されます。方程式を解く場合は、次節で扱う solve() やLU分解のように、逆行列を作らずに連立方程式を直接解く手法を用いるべきです。

しかし、物理学の公式など、逆行列そのものが必要な場合もあります(例:グリーン関数の計算)。

use ndarray::arr2;
use ndarray_linalg::Inverse;

fn main() {
    let a = arr2(&[[1.0, 2.0],
                   [3.0, 4.0]]);

    let a_inv = a.inv().expect("Singular matrix");

    println!("Inverse matrix:\n{}", a_inv);

    // 確認: A * A⁻¹ = I (単位行列)
    println!("Check:\n{}", a.dot(&a_inv));
}

まとめ

  • 線形代数の高度な機能には ndarray-linalg を使用する。
  • ノルム、行列式、逆行列といった基本的な演算は、対応するトレイト(Norm, Determinant, Inverse)をインポートすることで利用可能になる。
  • 単に方程式を解くだけなら、逆行列を作って を計算するのではなく、solve() や分解済み行列を使う。特に条件数が大きい行列では、丸め誤差や入力誤差が解に大きく増幅されやすい。
Last change: , commit: 656a13f

連立一次方程式

物理シミュレーションにおいて、最も頻繁に現れる計算タスクの一つが連立一次方程式を解くことです。

ここで、 は係数行列、 は既知のベクトル、 が求めたい未知のベクトルです。例えば、偏微分方程式を差分法や有限要素法で離散化すると、最終的にこの形の方程式(あるいはその大規模なもの)に帰着します。

直接法による解法

密行列(Dense Matrix)の場合、ガウスの消去法(より具体的にはLU分解)を用いるのが一般的です。ndarray-linalgsolve() は、一般行列に対して LU 分解を行い、その分解結果を使って連立方程式を解きます。LAPACK のルーチン名で言えば、分解は *getrf、分解済み行列による求解は *getrs に対応します。

solve メソッド

最も簡単な方法は、Solve トレイトの solve メソッドを使うことです。

use ndarray::{arr1, arr2};
use ndarray_linalg::Solve;

fn main() {
    // 係数行列 A
    let a = arr2(&[[3.0, 1.0],
                   [1.0, 2.0]]);

    // 右辺ベクトル b
    let b = arr1(&[9.0, 8.0]);

    // Ax = b を解く
    let x = a.solve(&b).expect("Failed to solve");

    println!("Solution x = {}", x);
    // 期待される解:
    // 3x + y = 9
    // x + 2y = 8
    // -> x=2, y=3
}

LU分解 (LU Decomposition)

同じ行列 に対して、異なる で何度も方程式を解く必要がある場合、 毎回 solve を呼ぶのは非効率です。 solve は内部で のコストがかかるLU分解(*getrf 相当)を行い、その後に前進・後退代入(*getrs 相当)で解いているからです。

一度LU分解を行って分解結果を保存しておけば、 次回以降は前進・後退代入だけで解を得ることができます。 この部分の計算量は、右辺ベクトル1本あたり です。 ここで 行列のサイズです。

計算量を分けて考えると、LU分解を作る部分が 、 分解済みのLUで1つの右辺を解く部分が です。

したがって、同じ係数行列で多数の右辺を解く問題では、 LU分解を一度だけ作り、その分解結果を使い回すことが重要です。

use ndarray::{arr1, arr2, Array1, Array2};
use ndarray_linalg::{Factorize, Solve}; // LU分解のために必要

fn solve_two_rhs_with_factorization(
    a: &Array2<f64>,
    b1: &Array1<f64>,
    b2: &Array1<f64>,
) -> (Array1<f64>, Array1<f64>) {
    let f = a.factorize().expect("Factorization failed");
    let x1 = f.solve(b1).expect("Failed to solve b1");
    let x2 = f.solve(b2).expect("Failed to solve b2");
    (x1, x2)
}

fn main() {
    let a = arr2(&[[3.0, 1.0],
                   [1.0, 2.0]]);

    // 1つ目の b に対して解く
    let b1 = arr1(&[9.0, 8.0]);
    // 3x + y = 4, x + 2y = 3 -> x = 1, y = 1
    let b2 = arr1(&[4.0, 3.0]);
    let (x1, x2) = solve_two_rhs_with_factorization(&a, &b1, &b2);
    println!("x1 = {}", x1);
    println!("x2 = {}", x2);
}

特別な行列の解法

行列 が特定の性質(対称、正定値など)を持つ場合、より特化したアルゴリズムを用いることで計算を高速化・安定化できます。

コレスキー分解 (Cholesky Decomposition)

エルミート行列(実対称行列) かつ正定値(Positive Definite) である場合、コレスキー分解が利用できます。

コレスキー分解はLU分解に比べて計算量が約半分で済み、数値的にも非常に安定しています。物理の問題(例:バネ系や構造解析の剛性行列、拡散問題の係数行列など)では、行列が対称正定値になることがよくあります。

use ndarray::arr2;
use ndarray_linalg::Cholesky;
use ndarray_linalg::UPLO;

fn main() {
    // 対称正定値行列
    let a = arr2(&[[4.0, 1.0],
                   [1.0, 4.0]]);

    // コレスキー分解 (Lower triangular)
    let l = a.cholesky(UPLO::Lower).expect("Cholesky failed");

    println!("L =\n{}", l);
    println!("L * L^T =\n{}", l.dot(&l.t()));
    // L * L^T = A となるはず

    // 分解結果を使って方程式を解くことも可能
    // (APIの詳細はバージョンによりますが、通常 solve メソッドなどが提供されます)
}

数値的安定性と条件数

連立方程式を解く際、条件数 (Condition Number) が重要になります。条件数は、行列に対する入力の小さな変化や丸め誤差が、解にどれだけ増幅されうるかを表す指標です。

正則行列 と、同じ種類の行列ノルムを用いて、条件数は次のように定義されます。

条件数が非常に大きい行列は「悪条件(ill-conditioned)」であると言われます。この場合、 のわずかな誤差が、解 に大きく現れる可能性があります。逆に条件数が小さい行列は、相対的に安定に解きやすい行列です。

LAPACK などの線形代数ライブラリでは、条件数そのものではなく、その逆数 (reciprocal condition number, rcond)を推定することも多くあります。rcond に近いほど、行列が特異に近い、または数値的に扱いにくいことを示します。

物理シミュレーションで奇妙な結果が出た場合、行列が特異に近い(条件数が大きい)状態になっていないか確認することが重要です。

反復法について

ここまでに扱った solve()、LU分解、コレスキー分解は、行列を明示的に持って分解する直接法です。一方、大規模な問題では、CG法やGMRES法などの反復法が有効になることがあります。

反復法は、行列 を完全な密行列として保持できない場合でも、積 を高速に計算できれば使えます。典型例は、 が疎行列で行列ベクトル積を非ゼロ要素だけから計算できる場合や、 が微分演算子・畳み込み・FFTを含む作用素として実装されている場合です。

ただし、反復法の収束は条件数、固有値・特異値の分布、前処理(preconditioning)の有無に強く依存します。単に大規模だから反復法が必ず速いわけではなく、「 が安く、十分な収束が得られるか」を確認する必要があります。

参考リンク

まとめ

  • 一般の連立一次方程式には Solve トレイトの solve メソッド(LU分解ベース)を使用する。
  • 同じ係数行列で何度も解く場合は、factorize でLU分解の結果を保存する。 分解には かかるが、分解済みなら各右辺は で解ける。
  • 行列が対称正定値であることが分かっている場合は、コレスキー分解 (Cholesky) を用いると効率的である。
  • 大規模問題では、 を高速に計算できるなら反復法も候補になる。
Last change: , commit: 656a13f

固有値問題

行列 に対して、以下の関係を満たすスカラー と非ゼロベクトル を求める問題を固有値問題と呼びます。

ここで 固有値 (Eigenvalue) 固有ベクトル (Eigenvector) と呼びます。 物理学において、固有値問題は極めて重要です。

  • 量子力学:シュレーディンガー方程式 は、ハミルトニアン行列 の固有値問題そのものです(固有値 がエネルギー準位に対応)。
  • 振動・波動:連成振動の固有振動数やモード解析。
  • データ解析:主成分分析 (PCA) による次元削減。

ndarray-linalg による解法

ndarray-linalg は、LAPACK のルーチンを用いて効率的に固有値と固有ベクトルを計算します。

一般の行列

正方行列の固有値・固有ベクトルを求めるには、Eig トレイトを使用します。

use ndarray::{arr2, Array1, Array2};
use num_complex::Complex64;
use ndarray_linalg::Eig;

fn eig_decomposition(a: &Array2<f64>) -> (Array1<Complex64>, Array2<Complex64>) {
    a.eig().expect("Eig decomposition failed")
}

fn main() {
    let a = arr2(&[[0.0, -1.0],
                   [1.0,  0.0]]);

    // 固有値と固有ベクトルを計算
    let (evals, evecs) = eig_decomposition(&a);

    println!("Eigenvalues: {}", evals);
    println!("Eigenvectors:\n{}", evecs);

    // 回転行列 [[0, -1], [1, 0]] の固有値は +/- i
    // 出力は Complex64 型になります
}

Note

実非対称行列の固有値は一般に複素数になるため、結果は Complex64 型の配列として返されることが多いです。複素数を直接操作する場合はnum-complex クレートの依存関係を追加する必要があります。

エルミート行列(実対称行列)

物理学で現れる行列の多くは、エルミート行列(実数の場合は対称行列)です。エルミート行列の固有値は必ず実数になり、固有ベクトルは直交するという性質があります。 この場合、Eigh トレイトを用いることで、計算を高速化し、結果を実数型 (f64) で得ることができます。

use ndarray::{arr2, Array1, Array2};
use ndarray_linalg::Eigh;
use ndarray_linalg::UPLO;

fn symmetric_eigenvalues(a: &Array2<f64>) -> (Array1<f64>, Array2<f64>) {
    a.eigh(UPLO::Lower).expect("Eigh failed")
}

fn main() {
    // 対称行列(パウリ行列 sigma_x など)
    let a = arr2(&[[0.0, 1.0],
                   [1.0, 0.0]]);

    // 固有値と固有ベクトルを計算
    // UPLO::Lower は下三角部分のみを参照することを意味します(対称なので)
    let (evals, evecs) = symmetric_eigenvalues(&a);

    println!("Eigenvalues: {}", evals); // [-1, 1]
    println!("Eigenvectors:\n{}", evecs);
}

アルゴリズムの実装例:べき乗法 (Power Iteration)

ライブラリを使うのが実用的ですが、アルゴリズムの理解のためにべき乗法を実装してみましょう。これは、絶対値が最大の固有値(およびその固有ベクトル)を求めるシンプルな反復法です。

原理

任意の初期ベクトル に行列 を繰り返し掛けると、ベクトルは次第に最大固有値に属する固有ベクトルの方向へ収束します。

Rustによる実装

use ndarray::{arr2, Array1, Array2};
use ndarray_linalg::Norm;

fn power_iteration(a: &Array2<f64>, max_iter: usize, tol: f64) -> (f64, Array1<f64>) {
    let n = a.nrows();
    // 初期ベクトル(ランダムまたは適当な値)
    let mut x = Array1::from_elem(n, 1.0);
    x = &x / x.norm_l2(); // 正規化

    let mut eigenvalue = 0.0;

    for _ in 0..max_iter {
        let x_next = a.dot(&x);
        let x_next_norm = x_next.norm_l2();

        // レイリー商による固有値の推定: λ ≈ xᵀAx / xᵀx
        // ここでは単純にノルムの比に近いが、厳密には内積をとる方が精度が良い
        let next_val = x.dot(&x_next);

        // 収束判定
        if (next_val - eigenvalue).abs() < tol {
            eigenvalue = next_val;
            x = &x_next / x_next_norm;
            break;
        }

        eigenvalue = next_val;
        x = &x_next / x_next_norm;
    }

    (eigenvalue, x)
}

fn main() {
    let a = arr2(&[[2.0, 1.0],
                   [1.0, 3.0]]);

    let (eval, evec) = power_iteration(&a, 1000, 1e-6);

    println!("Dominant Eigenvalue: {:.5}", eval);
    println!("Eigenvector: {}", evec);

    // 理論値:
    // trace=5, det=5 -> λ² - 5λ + 5 = 0
    // λ = (5 ± √(25 - 20))/2 = (5 ± 2.236)/2 = 3.618, 1.382
    // 最大固有値は約 3.618
}

まとめ

  • 一般の行列の固有値問題には eig を使用する。結果は複素数になることがある。
  • 対称行列(エルミート行列)の場合は eigh を使用する。計算が高速で、結果は実数になることが保証される。
  • 特定の固有値(最大固有値など)だけが必要な場合は、べき乗法などの反復法が有効な場合があるが、基本的にはLAPACKバインディングを利用するのが最も信頼性が高い。
Last change: , commit: ec78068

スパース行列

物理シミュレーション、特に偏微分方程式を差分法や有限要素法で離散化する場合、現れる行列は「ほとんどの成分がゼロ」という特徴を持ちます。このような行列をスパース行列(疎行列、Sparse Matrix) と呼びます。

例えば、1次元の拡散方程式を差分化すると、各点は隣接する2点と自分自身の計3点としか関係を持ちません。行列のサイズが であっても、非ゼロ成分は1行あたり3個、全体でも約3000個しかありません。これを の密行列(Dense Matrix)としてメモリに確保するのは、メモリ容量の無駄であり、計算速度も著しく低下します。

スパース行列の格納形式

スパース行列を効率的に扱うために、非ゼロ成分の値とそのインデックスだけを記録する形式が用いられます。代表的な形式に CSR (Compressed Sparse Row) 形式があります。

CSR形式

以下の行列 を考えます。

Sparse Matrix Visualization

CSR形式では、以下の3つの配列でこれを表現します。

  1. values: 非ゼロ成分の値(行順に詰める)
    [1, 2, 3, 4, 5, 6]
  2. col_indices: 各非ゼロ成分の列番号
    [0, 3, 1, 0, 2, 3]
  3. row_offsets: 各行の開始位置(values配列内でのインデックス)。最後の要素は非ゼロ成分の総数。
    [0, 2, 3, 5, 6]
    • 0行目は values[0..2]
    • 1行目は values[2..3]

この形式により、行列ベクトル積 を計算する際、ゼロ成分の計算をスキップでき、計算量を から は非ゼロ成分数)に削減できます。

sprs クレートによる実装

Rustでスパース行列を扱うためのクレートとして sprs があります。ndarray とも相互運用性があります。

Cargo.toml に以下を追加します。

[dependencies]
sprs = "0.11"
ndarray = "0.17"

スパース行列の構築

最も簡単な構築方法は、(行, 列, 値) のトリプレット形式でデータを準備し、そこからCSR形式などに変換することです。

use sprs::TriMat;
use ndarray::arr1;

fn sample_sparse_matrix() -> sprs::CsMat<f64> {
    let rows = 4;
    let cols = 4;
    // トリプレット形式 (Triplet Format) で初期化
    let mut triplet = TriMat::new((rows, cols));

    // (row, col, value) を追加
    triplet.add_triplet(0, 0, 1.0);
    triplet.add_triplet(0, 3, 2.0);
    triplet.add_triplet(1, 1, 3.0);
    triplet.add_triplet(2, 0, 4.0);
    triplet.add_triplet(2, 2, 5.0);
    triplet.add_triplet(3, 3, 6.0);

    // CSR形式に変換
    triplet.to_csr::<usize>()
}

fn main() {
    let a_csr = sample_sparse_matrix();

    println!("CSR Matrix:\n{:?}", a_csr);

    // 行列ベクトル積
    let x = arr1(&[1.0, 1.0, 1.0, 1.0]);

    // sprsの行列とndarrayのベクトルの積
    // 結果も ndarray のベクトルになる
    let y = &a_csr * &x;

    println!("y = A * x = {}", y);
    // 期待値:
    // row0: 1*1 + 0 + 0 + 2*1 = 3
    // row1: 3*1 = 3
    // row2: 4*1 + 5*1 = 9
    // row3: 6*1 = 6
    // y = [3, 3, 9, 6]
}

1次元ラプラシアン行列の例

物理シミュレーションでよく現れる、1次元の2階微分演算子(ラプラシアン)を差分化した行列を作成してみましょう。

行列の形は三重対角行列(Tridiagonal Matrix)になります。

use sprs::TriMat;

fn laplacian_1d(n: usize) -> sprs::CsMat<f64> {
    let mut triplet = TriMat::new((n, n));

    for i in 0..n {
        // 対角成分 -2.0
        triplet.add_triplet(i, i, -2.0);

        // 隣接成分 1.0
        if i > 0 {
            triplet.add_triplet(i, i - 1, 1.0);
        }
        if i < n - 1 {
            triplet.add_triplet(i, i + 1, 1.0);
        }
    }

    triplet.to_csr::<usize>()
}

fn main() {
    let n = 10; // グリッド点数
    let laplacian = laplacian_1d(n);
    // これでサイズが大きくてもメモリ効率よく保持できる
}

スパース行列の連立一次方程式

スパース行列 を係数とする連立方程式 を解く場合、密行列用のガウスの消去法(LU分解)をそのまま適用すると、分解過程でゼロだった部分に値が入ってしまう(フィルイン)現象が起き、スパース性が失われる可能性があります。

そのため、大規模なスパース行列に対しては、以下の手法が用いられます。

  1. 反復法 (Iterative Methods): 共役勾配法 (Conjugate Gradient Method) など。行列を直接変形せず、行列ベクトル積だけを使って解を徐々に改善していく方法。メモリ効率が非常に良い。
  2. スパース直接法: フィルインを最小限に抑えるように行や列を並べ替えてから分解を行う高度な手法。

本書の非線形方程式と最適化では、共役勾配法のアルゴリズムについても触れる予定です。

まとめ

  • 物理シミュレーションでは、成分のほとんどがゼロであるスパース行列が頻出する。
  • sprs クレートを用いることで、RustでCSR形式などのスパース行列を扱える。
  • スパース行列を用いることで、メモリ使用量と計算時間(特に行列ベクトル積)を劇的に削減できる。
Last change: , commit: ec78068

非線形方程式と最適化

Important

この章を読む前に

この章を読むには、以下の章を先に読んでおく必要があります。

本章では、非線形な方程式を数値的に解く方法と、関数の最小値(または最大値)を求める最適化問題について扱います。

非線形方程式とは

線形代数の章で扱った方程式は、 という形式で書ける「線形」なものでした。これらは行列の分解などを用いて、有限回の手順で厳密解(計算誤差を除いて)を求めることができます。

しかし、物理の世界にはそのような単純な形では書けない方程式が溢れています。

あるいは、複数の変数が絡み合う連立非線形方程式:

これらの方程式には、一般に解析的な解の公式が存在しません(あるいは非常に複雑です)。そのため、適当な初期値から出発して、反復計算 (Iterative Method) によって徐々に真の解に近づいていく数値解法が必要になります。

本章の構成

  1. 非線形方程式の解法 1変数の非線形方程式 の解(根)を見つける基本的なアルゴリズム(二分法、ニュートン法)を学びます。

  2. 多変数ニュートン法 変数が複数ある場合の連立非線形方程式 の解き方を学びます。ここでは線形代数の知識(ヤコビ行列)が必要になります。

  3. 関数の最適化 エネルギー最小化問題など、物理学で極めて重要な「関数の値を最小にする変数を求める」問題について、勾配降下法を中心に解説します。

作業テーマ

作業ディレクトリは ~/rust-computational-physics-work/ からの相対パスです。 ユニットテストで確認する具体的なケースは、解析解、境界条件、許容誤差の観点からAI coding agentと相談して決めます。

テーマ作業ディレクトリ構造化するコードユニットテスト演習拡張演習
二分法とニュートン法nonlinear/root-findingbisection, newton, RootResultx^2 - 2 = 0 で解と残差を確認する収束失敗をResultで返す
多変数ニュートン法nonlinear/multivariable-newtonresidual, jacobian, newton_step小さい連立方程式で残差減少を確認する反復履歴をCSVに出力する
最適化nonlinear/optimizationgradient_descent, line_search, grad_norm二次関数の最小点を確認するstep sizeを変えた収束比較を追加する

検証と実装の観点

非線形問題では、収束したように見える値が物理的・数値的に妥当とは限りません。 反復回数、残差、停止条件、初期値依存性を結果と一緒に確認します。

  • 二分法では、初期区間で符号変化があるかを必ず確認する。
  • ニュートン法では、導関数が小さい点、発散、反復回数上限を扱う。
  • 最適化では、勾配ノルム、step size、目的関数の減少を記録する。
  • 収束失敗はpanic!ではなく、可能な範囲でResultとして呼び出し側へ返す。

非線形方程式の解法

ある関数 に対して、

を満たす を求める問題を求根 (Root finding) と呼びます。

例えば、 の正の解は ですが、このような代数方程式だけでなく、 のような超越方程式を解く場面も物理では頻繁に現れます。

本節では、代表的な2つの反復解法である二分法ニュートン法を紹介します。

二分法 (Bisection Method)

二分法は、解が存在する区間を半分ずつに狭めていくことで解を追い詰める、非常にシンプルで堅牢な手法です。

アルゴリズム

「連続関数 において、 の符号が異なれば、区間 の間に少なくとも一つ解が存在する」という中間値の定理に基づいています。

  1. 解が含まれる初期区間 を用意する(ただし であること)。
  2. 区間の中点 を計算する。
  3. の符号を調べる。
    • なら、 が解である。
    • が異符号なら、解は にあるので、 と更新する。
    • が異符号なら、解は にあるので、 と更新する。
  4. 区間の幅 が許容誤差 より小さくなるまで繰り返す。

Rustによる実装

例として、 を解いて を求めてみましょう。

fn bisection<F>(f: F, mut a: f64, mut b: f64, tolerance: f64, max_iter: usize) -> Option<(f64, usize)>
where
    F: Fn(f64) -> f64,
{
    for i in 0..max_iter {
        let c = (a + b) / 2.0;
        let fc = f(c);

        if fc.abs() < tolerance || (b - a).abs() < tolerance {
            return Some((c, i + 1));
        }

        // f(a) * f(c) < 0 なら左側に解がある
        if f(a) * fc < 0.0 {
            b = c;
        } else {
            a = c;
        }
    }

    None
}

fn main() {
    // 解きたい関数: f(x) = x^2 - 2
    let f = |x: f64| x * x - 2.0;

    let tolerance = 1e-8; // 許容誤差
    let max_iter = 100;   // 最大反復回数(無限ループ防止)

    // 初期区間 [1.0, 2.0] には解がある
    match bisection(f, 1.0, 2.0, tolerance, max_iter) {
        Some((root, iter)) => println!("解が見つかりました: x = {:.10} (反復回数: {})", root, iter),
        None => println!("収束しませんでした。"),
    }
}

特徴

  • 長所: 初期区間に解があれば、必ず収束する(大域的収束性)。
  • 短所: 収束が遅い(1回の反復で精度が1ビット、つまり2進数で1桁しか良くならない)。

ニュートン法 (Newton’s Method)

ニュートン法(ニュートン・ラフソン法) は、関数の微分情報 を利用して、より高速に解に収束させる手法です。

アルゴリズム

現在の推定値 における接線を引き、その接線と 軸との交点を次の推定値 とします。 テイラー展開の1次近似からも導出できます。

これを について解くと更新式が得られます。

Rustによる実装

同様に を解きます。ここでは導関数 を利用します。

fn newton<F, G>(f: F, df: G, mut x: f64, tolerance: f64, max_iter: usize) -> Option<(f64, usize)>
where
    F: Fn(f64) -> f64,
    G: Fn(f64) -> f64,
{
    for i in 0..max_iter {
        let fx = f(x);

        if fx.abs() < tolerance {
            return Some((x, i));
        }

        let dfx = df(x);
        // 接線の傾きが0に近いと発散の危険がある
        if dfx.abs() < 1e-10 {
            return None;
        }

        x -= fx / dfx;
    }

    None
}

fn main() {
    let f = |x: f64| x * x - 2.0;
    let df = |x: f64| 2.0 * x; // f(x) の導関数

    let tolerance = 1e-8;
    let max_iter = 100;

    match newton(f, df, 1.0, tolerance, max_iter) {
        Some((root, iter)) => println!("解が見つかりました: x = {:.10} (反復回数: {})", root, iter),
        None => println!("収束しませんでした。"),
    }
}

特徴

  • 長所: 解の近くでは非常に高速に収束する(2次収束: 正しい桁数が反復ごとに倍になる)。
  • 短所:
    • 導関数 が必要(数値微分で代用することも可能)。
    • 初期値が解から遠いと収束しないことがある(局所的収束性)。
    • となる場所では不安定になる。

どちらを使うべきか?

  • 二分法: とにかく安全に解きたいとき、関数の性質がよく分からないとき。
  • ニュートン法: 微分が計算でき、高速に解きたいとき。初期値の良い推定ができるとき。

実用的には、これらを組み合わせたブレント法 (Brent’s Method) などが多くの数値計算ライブラリで採用されています(最初は安全な二分法などを使い、解に近づいたら高速な手法に切り替えるアルゴリズムです)。

実用的なライブラリ (Brent法)

実務で求根を行う場合、Brent法を自前で実装するのは複雑でバグの原因になりやすいため、通常はライブラリを使用します。 Rustでは、例えば roots クレートなどが利用できます。

[dependencies]
roots = "0.0.8"

使用例:

use roots::find_root_brent;
use roots::SimpleConvergency;

fn solve_sqrt2_with_brent() -> Result<f64, roots::SearchError> {
    let f = |x: f64| x * x - 2.0;
    let mut convergency = SimpleConvergency { eps: 1e-15f64, max_iter: 30 };

    // 区間 [1.0, 2.0] で解を探す
    // find_root_brent(初期区間始点, 初期区間終点, 関数, 収束条件)
    find_root_brent(1.0, 2.0, &f, &mut convergency)
}

fn main() {
    match solve_sqrt2_with_brent() {
        Ok(val) => println!("解: {}", val),
        Err(e) => println!("エラー: {:?}", e),
    }
}

まとめ

  • 二分法は、解を挟む区間を縮小していく手法で、確実に収束するが速度は遅い。
  • ニュートン法は、微係数を利用して解を探索する手法で、高速に収束するが適切な初期値が必要である。
  • 実用的な問題では、安定性と速度を兼ね備えたブレント法が推奨される。Rustでは roots クレートを利用することで手軽に実装できる。

次節では、変数が複数ある場合の連立非線形方程式の解法について学びます。

Last change: , commit: 991b48c

多変数ニュートン法

前節では1変数の非線形方程式 を扱いましたが、物理シミュレーションでは複数の変数が相互に依存する連立方程式を解く必要がしばしば生じます。

これを解くための標準的な手法が、ニュートン法を多変数に拡張した多変数ニュートン法 (Multivariable Newton’s Method) です。

アルゴリズム

1変数の場合の更新式 をベクトルと行列に拡張します。

多変数のテイラー展開(1次近似)は以下のようになります。

ここで ヤコビ行列 (Jacobian Matrix) です。

実用上は、このヤコビ行列の扱いが大きな問題になります。未知数が 個なら 行列なので、要素数は 個あります。各要素は偏微分 であり、解析的に導出する場合も、差分で近似する場合も、自動微分を 使う場合も、「残差ベクトルを評価する」だけの場合より設計が難しくなります。

したがって、修正量 は以下の連立一次方程式を解くことで求められます。

これを解いて を更新します。

Rustによる実装

と放物線 の交点を求めてみましょう。

連立方程式は以下のようになります。

ヤコビ行列 は:

計算の具体例 (1ステップ目)

初期値を として、1回目の更新を手計算で追ってみましょう。

  1. 残差ベクトル の計算
  1. ヤコビ行列 の計算
  1. 修正量 の計算 連立方程式 を解きます。
これを解くと(クラメルの公式やガウス消去法などで)、
  1. 解の更新

次の推定値は となります。これを繰り返すことで、真の解(この場合は )に近づいていきます。

実装には ndarrayndarray-linalg を使用します。

Note

実行には Cargo.tomlndarrayndarray-linalg、およびバックエンド(例: openblas-src)の依存関係が必要です。

use ndarray::{arr1, arr2, Array1, Array2};
use ndarray_linalg::Solve;

fn residual(x: &Array1<f64>) -> Array1<f64> {
    let curr_x: f64 = x[0];
    let curr_y: f64 = x[1];

    arr1(&[
        curr_x.powi(2) + curr_y.powi(2) - 1.0,
        curr_y - curr_x.powi(2),
    ])
}

fn jacobian(x: &Array1<f64>) -> Array2<f64> {
    let curr_x: f64 = x[0];
    let curr_y: f64 = x[1];

    arr2(&[
        [2.0 * curr_x, 2.0 * curr_y],
        [-2.0 * curr_x, 1.0       ]
    ])
}

fn residual_norm(f_vec: &Array1<f64>) -> f64 {
    f_vec.iter().map(|v| v.powi(2)).sum::<f64>().sqrt()
}

fn newton_solve(mut x: Array1<f64>, tolerance: f64, max_iter: usize) -> Option<(Array1<f64>, usize)> {
    for i in 0..max_iter {
        // 残差ベクトル F(x)
        let f_vec = residual(&x);

        // 収束判定 (ノルムが十分小さいか)
        if residual_norm(&f_vec) < tolerance {
            return Some((x, i));
        }

        // ヤコビ行列 J(x)
        let j = jacobian(&x);

        // 連立一次方程式 J * delta_x = -F を解く
        // solve() は ndarray-linalg の機能
        let delta = j.solve(&(-f_vec)).expect("Singular Jacobian");

        // 更新
        x = x + delta;
    }

    None
}

fn main() {
    // 初期値 (x, y) = (1.0, 2.0)
    // 解に近い適切な初期値を選ぶ必要があります
    let x0 = arr1(&[1.0, 2.0]);

    let tolerance = 1e-8;
    let max_iter = 100;

    match newton_solve(x0, tolerance, max_iter) {
        Some((x, iter)) => println!("解が見つかりました: x={:.6}, y={:.6} (反復: {})", x[0], x[1], iter),
        None => println!("収束しませんでした"),
    }
}

解説

  1. ループ: 1変数のときと同様に、収束するまでループします。
  2. ヤコビ行列の評価: jacobian(&x) は今回のような の問題では簡単ですが、未知数が多い問題では 個の偏微分を評価・保存する必要があります。PDEを離散化して得られる問題ではヤコビ行列が疎になることが多く、その構造を使わずに密行列として扱うとすぐに破綻します。
  3. 連立一次方程式: j.solve(&(-f_vec)) の部分で、線形代数のソルバーが活躍します。密行列として解くと一般に の計算量が必要です。大規模な物理シミュレーションでは、ヤコビ行列の疎性を使うソルバーや、ヤコビ行列を明示的に作らない Newton-Krylov 法などを検討します。
  4. 逆行列: 数式上は と書けますが、数値計算では逆行列 を明示的に求めてはいけません。連立方程式 を解く方が、計算量的にも精度的にも有利だからです。

応用例

多変数ニュートン法は以下のような場面で使用されます。

  • 陰的解法 (Implicit Methods): 時間発展の方程式(微分方程式)を解く際、次の時刻の状態を求めるために非線形方程式を解く必要がある場合(後退オイラー法など)。
  • 構造解析: 大きな変形を伴う物体の釣り合い位置を求める問題。

まとめ

  • 多変数ニュートン法は、1変数のニュートン法をベクトルと行列に一般化した手法である。
  • 各ステップでヤコビ行列 を計算し、連立一次方程式 を解くことで修正量を求める。
  • 未知数が 個ならヤコビ行列は 個の要素を持つため、大規模問題では評価方法、疎性、保存形式を含めて設計する必要がある。
  • 逆行列 を計算するのではなく、LU分解などの線形ソルバーを用いるのが数値計算の鉄則である。
  • この手法は、非線形微分方程式の陰的解法など、物理シミュレーションの多くの場面で利用される。

次節では、方程式を解くことと密接に関連する「最適化問題」について扱います。

Last change: , commit: ec78068

関数の最適化

物理学において、ある関数の最小値(または最大値)を求める最適化 (Optimization) は極めて重要なテーマです。

  • ポテンシャルエネルギーの最小化: 系はエネルギーが低い安定な状態に向かいます。
  • モデルフィッティング: 実験データと理論モデルの誤差(最小二乗法など)を最小にするパラメータを探します。
  • 作用最小の原理: 古典力学の運動は作用積分を最小にする経路をとります。

関数の最小値を与える点では微分(勾配)が になるため、最適化は「導関数 となる解を見つける」という点で求根問題と密接に関連しています。

勾配降下法 (Gradient Descent)

最も基本的かつ直感的な手法が勾配降下法です。 「今の場所で一番急な下り坂の方向へ少し進む」ことを繰り返して、谷底(極小値)を目指します。

アルゴリズム

多変数関数 の勾配 は、関数が最も増加する方向を向いています。したがって、その逆方向 へ進めば値を小さくできます。

ここで 学習率 (Learning Rate) と呼ばれる正のパラメータで、一歩の大きさを調整します。

Rustによる実装

関数 (お椀型の関数)の最小値 を求めてみましょう。

fn sphere(x: &[f64]) -> f64 {
    x[0].powi(2) + x[1].powi(2)
}

fn sphere_gradient(x: &[f64]) -> Vec<f64> {
    vec![2.0 * x[0], 2.0 * x[1]]
}

fn gradient_norm(g: &[f64]) -> f64 {
    g.iter().map(|v| v.powi(2)).sum::<f64>().sqrt()
}

fn gradient_descent(mut x: Vec<f64>, alpha: f64, tolerance: f64, max_iter: usize) -> (Vec<f64>, f64, usize) {
    for i in 0..max_iter {
        let current_val = sphere(&x);
        let g = sphere_gradient(&x);

        // 勾配の大きさが十分小さくなったら終了
        if gradient_norm(&g) < tolerance {
            return (x, current_val, i);
        }

        // 更新 x = x - alpha * grad
        for j in 0..x.len() {
            x[j] -= alpha * g[j];
        }

        println!("iter {}: x={:?}, f(x)={:.6}", i, x, current_val);
    }

    let value = sphere(&x);
    (x, value, max_iter)
}

fn main() {
    let x0 = vec![2.0, 1.0]; // 初期値
    let alpha = 0.4;            // 学習率
    let tolerance = 1e-6;
    let max_iter = 100;

    let (x, value, iter) = gradient_descent(x0, alpha, tolerance, max_iter);
    println!("\n終了: x={:?}, f(x)={:.6} (反復: {})", x, value, iter);
}

学習率の重要性

  • 小さすぎると、収束までに非常に時間がかかります。
  • 大きすぎると、谷底を飛び越えてしまい、振動したり発散したりします。

実際に を変えて試してみると、その影響がよく分かります。

局所解と大域解

勾配降下法やニュートン法などの反復法には、「初期値の近くにある谷底(局所的最適解:Local Minima)」には辿り着けますが、本当の最小値(大域的最適解:Global Minima)が見つかるとは限らないという弱点があります。

複雑な地形(多峰性関数)で大域解を見つけるには、より高度な手法(焼きなまし法、遺伝的アルゴリズム、あるいは初期値を多数変えて試すなど)が必要になります。

実用的なライブラリ (argmin)

勾配降下法は単純ですが、収束が遅い場合があります(特に谷が細長い場合)。実務でより複雑な問題を解くには、argmin クレートなどの最適化ライブラリを使用するのが一般的です。

ここでは、最適化のベンチマークとして有名な Rosenbrock関数 を、強力な準ニュートン法の一種である L-BFGS法 で解いてみます。

この関数は に最小値を持ちますが、細長いバナナ状の谷があるため、単純な勾配降下法では収束に非常に時間がかかります。

L-BFGS法とは?

L-BFGS (Limited-memory BFGS) 法は、準ニュートン法と呼ばれるアルゴリズムの一種です。

  • ニュートン法との違い: ニュートン法は2階微分(ヘッセ行列)を利用して高速に収束しますが、変数の数 に対して のメモリと の計算コストが必要です。
  • 準ニュートン法 (BFGS): ヘッセ行列を直接計算せず、過去の勾配の履歴から「ヘッセ行列の逆行列」を逐次近似します。
  • 限定メモリ (L-BFGS): 近似に必要な履歴をすべて保存するのではなく、直近の ステップ分だけを保持することで、メモリ消費を にまで抑えます。

この「省メモリかつ高速」という性質により、数万個の原子を扱う分子動力学の構造最適化など、大規模な物理問題におけるデファクトスタンダードとなっています。

argmin は数学的な演算部分を抽象化しているため、ndarray の配列をパラメータとして使うには、対応するバックエンドを提供する argmin-math が必要になります。

Cargo.toml:

[dependencies]
argmin = "0.11"
argmin-math = { version = "0.5", features = ["ndarray_latest"] }
ndarray = "0.16"
ndarray-linalg = { version = "0.17", features = ["openblas-system"] }

使用例:

use argmin::core::{CostFunction, Error, Executor, Gradient};
use argmin::solver::linesearch::MoreThuenteLineSearch;
use argmin::solver::quasinewton::LBFGS;
use ndarray::{Array1, array};

struct Rosenbrock {}

// 1. 目的関数の定義: f(x)
impl CostFunction for Rosenbrock {
    type Param = Array1<f64>;
    type Output = f64;

    fn cost(&self, p: &Self::Param) -> Result<Self::Output, Error> {
        let (x, y) = (p[0], p[1]);

        Ok((1.0 - x).powi(2) + 100.0 * (y - x.powi(2)).powi(2))
    }
}

// 2. 勾配 (1階微分) の定義: grad f(x)
impl Gradient for Rosenbrock {
    type Param = Array1<f64>;
    type Gradient = Array1<f64>;

    fn gradient(&self, p: &Self::Param) -> Result<Self::Gradient, Error> {
        let (x, y) = (p[0], p[1]);

        let gx = -2.0 * (1.0 - x) - 400.0 * x * (y - x.powi(2));
        let gy = 200.0 * (y - x.powi(2));

        Ok(array![gx, gy])
    }
}

fn rosenbrock_initial_param() -> Array1<f64> {
    array![-1.2, 1.0]
}

fn main() {
    let cost = Rosenbrock {};
    let init_param = rosenbrock_initial_param();

    // L-BFGSソルバーの設定
    // 準ニュートン法では、更新の方向を決めた後に「どれだけ進むか」を決める
    // 行探索 (Line Search) アルゴリズムが必須となります。
    let linesearch = MoreThuenteLineSearch::new();

    // L-BFGS::new(行探索, 記憶する履歴の数)
    // 履歴の数(m)は通常 3~10 程度で十分な性能を発揮します。
    let solver = LBFGS::new(linesearch, 7);

    // 3. Executorによる実行
    let res = Executor::new(cost, solver)
        .configure(|state| {
            state
                .param(init_param) // 初期値
                .max_iters(100) // 最大反復回数
                .target_cost(1e-10) // 目標値に達したら終了
        })
        .run()
        .expect("Optimization failed");

    // 結果の表示 (argmin 0.11では OptimizationResult が返る)
    println!("Result: {}", res);
}

なぜライブラリを使うのか?

  • 収束速度: L-BFGSなどの高度な手法は、勾配降下法よりも遥かに少ない反復回数で解に到達します。
  • 頑健性: 行き過ぎを防ぐための「行探索 (Line Search)」などが組み込まれており、発散しにくいです。
  • スケーラビリティ: argmin は数万次元以上の大規模な問題にも対応できる設計になっています。

まとめ

  • 最適化は、エネルギー最小化やデータフィッティングなど、物理シミュレーションの基盤となる重要な手法である。
  • 勾配降下法は、勾配の逆方向に進むことで最小値を探索する最も基本的なアルゴリズムである。
  • パラメータの更新幅を決める学習率の選択は、収束速度や安定性に大きな影響を与える。
  • 多くの反復法は局所最適解に陥る可能性があり、問題に応じて初期値の選択や高度なアルゴリズムの検討が必要である。

本章はこれで終わりです。次はフーリエ解析に進みましょう。

Last change: , commit: 991b48c

フーリエ解析

Important

この章を読む前に

この章を読むには、以下の章を先に読んでおく必要があります。

物理学や工学において、信号を周波数成分に分解するフーリエ解析 (Fourier Analysis) は欠かせないツールです。

フーリエ解析とは

複雑な波動や時間信号を、単純な正弦波(サイン・コサイン)の重ね合わせとして表現する手法です。これにより、時間領域(Time Domain)では見えにくかった信号の特徴を、周波数領域(Frequency Domain)で明確に捉えることができます。

物理学における応用例:

  • 波動の解析: 音波、光、地震波などのスペクトル分析。
  • 量子力学: 座標表現と運動量表現の間の変換。
  • 微分方程式の解法: フーリエ変換を用いることで、偏微分方程式を代数方程式に変換して解くことができます。
  • ノイズ除去: 特定の周波数成分をカットするフィルタリング。

本章の構成

  1. 離散フーリエ変換の基礎 有限個のデータ点に対して定義される離散フーリエ変換 (DFT) の定義と、その直接的な計算方法を学びます。

  2. 高速フーリエ変換 (FFT) 計算量を劇的に削減する高速フーリエ変換 (FFT) アルゴリズムの原理と、Rustでの実装・ライブラリの利用方法について学びます。

  3. スペクトル解析 実際の信号データに対してFFTを適用し、パワースペクトルの計算や窓関数の重要性について実践的に学びます。

作業テーマ

作業ディレクトリは ~/rust-computational-physics-work/ からの相対パスです。 ユニットテストで確認する具体的なケースは、解析解、境界条件、許容誤差の観点からAI coding agentと相談して決めます。

テーマ作業ディレクトリ構造化するコードユニットテスト演習拡張演習
DFTの基礎fourier/dft-basicsdft, idft, complex_expimpulseや定数列のDFTを確認するDFTとIDFTの往復誤差を出力する
FFTfourier/fftrun_fft, check_power_of_two, normalize小さい入力でDFTとFFTを比較する入力サイズごとの実行時間をCSVに出力する
スペクトル解析fourier/spectral-analysisfrequency_axis, power_spectrum, window既知周波数の正弦波でピーク位置を確認する窓関数の有無を比較する

検証と実装の観点

フーリエ解析では、配列の長さ、正規化、周波数軸の定義が少しずれるだけで、 見た目は似ていても意味の異なる結果になります。

  • 周波数が既知の正弦波を入力し、ピーク位置と振幅の正規化を確認する。
  • DFTとFFTの結果を、小さい入力で比較する。
  • 逆変換を使う場合は、元の信号に戻るかを許容誤差つきで確認する。
  • sample数、sampling interval、window、正規化規約をmetadataとして残す。

離散フーリエ変換の基礎

Note

本節のポイント

  • 離散フーリエ変換(DFT)の数学的定義を理解する。
  • ndarrayを用いて、DFTを定義通りに実装する方法を学ぶ。
  • DFTを行列演算(線形変換)として解釈し、線形代数で学んだ知識と結びつける。
  • 直接計算の計算量 という課題を理解する。

コンピュータで扱うデータは、一定の時間間隔でサンプリングされた離散的な数値の列です。このような離散データに対してフーリエ変換を行う手法を離散フーリエ変換 (Discrete Fourier Transform, DFT) と呼びます。

DFTの定義

長さ の複素数データ系列 に対して、その離散フーリエ変換 は次のように定義されます。

ここで は虚数単位です。 は、元の信号に含まれる周波数成分(振幅と位相)を表します。

逆に、 から元の信号 を復元する逆離散フーリエ変換 (IDFT) は以下の通りです。

DFTの実装 (ndarray)

ndarray入門で学んだndarrayを使用して、定義式に基づいてDFTを計算するコードを実装してみましょう。複素数の扱いはnum-complexクレート(Complex64)を使用します。

[dependencies]
ndarray = "0.17"
num-complex = "0.4"
use ndarray::{Array1, ArrayView1};
use num_complex::Complex64;
use std::f64::consts::PI;

/// 定義式に基づいたDFTの直接計算
fn dft(x: ArrayView1<Complex64>) -> Array1<Complex64> {
    let n = x.len();
    let mut x_k = Array1::zeros(n);

    for k in 0..n {
        let mut sum = Complex64::new(0.0, 0.0);
        for n_idx in 0..n {
            let angle = -2.0 * PI * (k as f64) * (n_idx as f64) / (n as f64);
            // オイラーの公式 exp(iθ) = cos θ + i sin θ を用いた計算
            let exponent = Complex64::from_polar(1.0, angle);
            sum += x[n_idx] * exponent;
        }
        x_k[k] = sum;
    }
    x_k
}

fn main() {
    // 例:4点のデータ
    let data = Array1::from(vec![
        Complex64::new(1.0, 0.0),
        Complex64::new(2.0, 0.0),
        Complex64::new(3.0, 0.0),
        Complex64::new(4.0, 0.0),
    ]);

    let result = dft(data.view());

    for (k, val) in result.iter().enumerate() {
        println!("X[{}] = {:.3} + {:.3}i", k, val.re, val.im);
    }
}
X[0] = 10.000 + 0.000i
X[1] = -2.000 + 2.000i
X[2] = -2.000 + -0.000i
X[3] = -2.000 + -2.000i

線形代数としての解釈:DFT行列

線形代数で学んだように、DFTはベクトルに対する線形変換とみなすことができます。 とおくと、DFTは以下のような行列とベクトルの積で表現できます。

この行列を DFT行列 と呼びます。ndarrayの行列積dotを利用すれば、DFTはさらに簡潔に記述可能です。

fn dft_matrix_method(x: &Array1<Complex64>) -> Array1<Complex64> {
    let n = x.len();
    // DFT行列の作成
    let mut w = Array2::<Complex64>::zeros((n, n));
    for k in 0..n {
        for n_idx in 0..n {
            let angle = -2.0 * PI * (k as f64) * (n_idx as f64) / (n as f64);
            w[[k, n_idx]] = Complex64::from_polar(1.0, angle);
        }
    }
    // 行列とベクトルの積
    w.dot(x)
}

計算量と課題

いずれの実装でも、全ての周波数 に対して 回の和をとるため、計算量は となります。

データ数 が大きくなると計算時間が爆発的に増加します。

  • のとき、 回の複素数演算。
  • のとき、 回の複素数演算(一般的なPCでは実用困難)。

この の壁を打破し、実用的な速度でフーリエ変換を行う手法が、次節で学ぶ高速フーリエ変換 (FFT) です。


次節では、計算効率を劇的に改善する FFTのアルゴリズムとライブラリの使い方について学びます。

Last change: , commit: ec78068

高速フーリエ変換(FFT)

Note

本節のポイント

  • 高速フーリエ変換(FFT)が計算量を から へ劇的に削減する原理(分割統治法)を理解する。
  • Rustの標準的なFFTライブラリであるrustfftの使い方を習得する。
  • ndarrayと外部ライブラリ(スライスベースのAPI)を連携させる方法を学ぶ。

高速フーリエ変換 (Fast Fourier Transform, FFT) は、離散フーリエ変換(DFT)を効率的に計算するアルゴリズムの総称です。現在よく使われる Cooley-Tukey 型 FFT は、1965年にクーリー(J. W. Cooley)とテューキー(J. W. Tukey)によって再発見され、現代のデジタル信号処理の基盤となっています。

アルゴリズムの原理:分割統治法

最も一般的な「基数2のクーリー・テューキー型アルゴリズム」では、データ長 の累乗( )である場合、問題を再帰的に半分に分割していきます。

DFTの定義式を、データの偶数番目( )と奇数番目( )に分けます。

指数部分を整理すると:

これは、長さ のDFTが2つあることを意味します。この分割を繰り返すことで、計算量は から に削減されます。

データ数 NN² (DFT)N log₂ N (FFT)比率
1,024 (2¹⁰)約 10⁶約 10⁴約 100 倍
1,048,576 (2²⁰)約 10¹²約 2 × 10⁷約 50,000 倍

Rustでの利用: rustfft

物理計算の実務において、FFTを自前で実装することは教育的な目的以外では稀です。Rustでは、高性能なFFTライブラリであるrustfftを使用するのが標準的です。

依存関係

Cargo.tomlrustfftndarrayを追加します。

[dependencies]
rustfft = "6.4"
ndarray = "0.17"
num-complex = "0.4"

実装例(ndarray との連携)

多くの数値計算ライブラリは、特定のデータ構造(ndarrayなど)ではなく、汎用的なスライス(&mut [T])を要求します。ndarrayas_slice_mut()raw_view_mut()を使うことで、効率的にデータを渡すことができます。

use rustfft::FftPlanner;
use ndarray::Array1;
use num_complex::Complex64;

fn sample_data(n: usize) -> Array1<Complex64> {
    Array1::<Complex64>::from_iter((0..n).map(|i| {
        Complex64::new(i as f64 + 1.0, 0.0)
    }))
}

fn fft_in_place(data: &mut Array1<Complex64>) {
    let n = data.len();

    // FFTのプランを作成
    let mut planner = FftPlanner::new();
    let fft = planner.plan_fft_forward(n);

    // ndarrayの内部バッファをスライスとして取り出し、FFTを実行
    // processメソッドはインプレース(破壊的)に計算を行う
    fft.process(data.as_slice_mut().expect("Array must be contiguous"));
}

fn main() {
    let n = 8;
    // ndarrayでデータを作成
    let mut data = sample_data(n);
    fft_in_place(&mut data);

    println!("FFT 結果:");
    for (i, val) in data.iter().enumerate() {
        println!("{}: {:.3} + {:.3}i", i, val.re, val.im);
    }
}
FFT 結果:
0: 36.000 + 0.000i
1: -4.000 + 9.657i
2: -4.000 + 4.000i
3: -4.000 + 1.657i
4: -4.000 + 0.000i
5: -4.000 + -1.657i
6: -4.000 + -4.000i
7: -4.000 + -9.657i

注意点

  1. 正規化: rustfftを含む多くのライブラリでは、計算効率のために定義式の (または )といった正規化係数を省略しています。FFTとその逆変換(IFFT)を続けて行っても元の値には戻らないため、必要に応じて で割るなどの正規化が必要です。
  2. データの並び: FFTの結果は、周波数 (DC成分)から始まり、前半が正の周波数、後半が負の周波数(または高周波成分)という並びになります。
  3. メモリ連続性: as_slice_mut()はデータがメモリ上で連続している場合にのみ成功します。スライシングなどで非連続になったArrayに対しては、to_owned()as_standard_layout()で連続化する必要があります。

参考リンク


次節では、FFTを使って実際の信号の周波数を解析する方法を学びます。

Last change: , commit: 991b48c

スペクトル解析

Note

本節のポイント

  • FFTの出力結果を物理的な「周波数」と「強度」に対応させる方法を学ぶ。
  • スペクトル漏れを防ぐための窓関数の役割と、ndarrayを用いた適用方法を習得する。
  • plottersを用いて、計算結果を可視化する。

FFTを計算しただけでは、物理的な意味を持つ「周波数」は分かりません。FFTの出力結果をどのように解釈し、実際の信号を分析するか(スペクトル解析)について学びます。

周波数軸との対応

サンプリング周期を (サンプリング周波数 )、データ数を とします。 FFTの出力 の各インデックス に対応する物理的な周波数 は以下のようになります。

ここで、 が正の周波数成分、 が負の周波数成分に対応します。 ナイキスト周波数(再現可能な最高周波数)は です。

パワースペクトル(Power Spectrum)

各周波数成分の「強さ」を見るために、複素数 の絶対値の2乗をとったものをパワースペクトルと呼びます。

物理学では、エネルギーがどの周波数帯域に分布しているか(パワースペクトル密度, PSD)を調べることで、系の振動特性などを明らかにします。

窓関数(Window Function)

FFTは、入力信号が「周期 で無限に繰り返されている」ことを仮定しています。 実際の有限な信号をそのまま切り取ると、信号の端点で不連続が生じ、スペクトル漏れ (Spectral Leakage) というノイズが発生します。

これを防ぐために、信号の両端を滑らかに に落とす窓関数を事前に乗算します。ndarrayを使えば、信号ベクトルと窓関数ベクトルの要素ごとの積として簡潔に記述できます。

代表的な窓関数:

  • Hann窓(Hann window, Hanning window とも呼ばれる): 最も一般的に使われる窓関数で、周波数分解能とスペクトル漏れの抑制のバランスが良い。
  • ハミング窓(Hamming window): Hann窓に似ているが、端点が になりきらない。周波数分解能を少し優先したい場合に用いられる。
  • ブラックマン窓(Blackman window): Hann窓よりもスペクトル漏れを強力に抑えるが、メインローブが広くなり周波数分解能は低下する。

実践例:合成信号の解析と可視化

2つの異なる周波数のサイン波を含む信号をFFTし、その周波数を特定してみましょう。数値計算結果の可視化で触れたplottersを用いた可視化も行います。

依存関係

[dependencies]
rustfft = "6.4"
ndarray = "0.17"
num-complex = "0.4"
plotters = "0.3"

実装例

use rustfft::FftPlanner;
use ndarray::Array1;
use num_complex::Complex64;
use std::f64::consts::PI;
use plotters::prelude::{
    BitMapBackend, ChartBuilder, IntoDrawingArea, LineSeries, RED, WHITE,
};

fn time_grid(n: usize, dt: f64) -> Array1<f64> {
    Array1::from_shape_fn(n, |i| i as f64 * dt)
}

fn synthetic_signal(times: &Array1<f64>) -> Array1<Complex64> {
    times.mapv(|ti| {
        let val = 1.0 * (2.0 * PI * 50.0 * ti).sin()
            + 0.5 * (2.0 * PI * 120.0 * ti).sin();
        Complex64::new(val, 0.0)
    })
}

fn hann_window(n: usize) -> Array1<f64> {
    Array1::from_shape_fn(n, |i| {
        0.5 * (1.0 - (2.0 * PI * i as f64 / (n as f64 - 1.0)).cos())
    })
}

fn apply_real_window(signal: &mut Array1<Complex64>, window: &Array1<f64>) {
    signal.zip_mut_with(window, |value, &weight| {
        *value *= Complex64::new(weight, 0.0);
    });
}

fn fft_in_place(signal: &mut Array1<Complex64>) {
    let mut planner = FftPlanner::new();
    let fft = planner.plan_fft_forward(signal.len());
    fft.process(signal.as_slice_mut().expect("Array must be contiguous"));
}

fn positive_frequency_power(signal: &Array1<Complex64>, fs: f64) -> (Vec<f64>, Vec<f64>) {
    let n = signal.len();
    let freqs: Vec<f64> = (0..n / 2).map(|k| k as f64 * fs / n as f64).collect();
    let powers: Vec<f64> = signal.iter().take(n / 2).map(|c| c.norm_sqr()).collect();

    (freqs, powers)
}

fn plot_power_spectrum(
    freqs: &[f64],
    powers: &[f64],
    fs: f64,
    path: &str,
) -> Result<(), Box<dyn std::error::Error>> {
    let y_max = powers.iter().copied().fold(0.0_f64, f64::max);

    let root = BitMapBackend::new(path, (800, 600)).into_drawing_area();
    root.fill(&WHITE)?;

    let mut chart = ChartBuilder::on(&root)
        .caption("Power Spectrum", ("sans-serif", 30))
        .margin(10)
        .x_label_area_size(40)
        .y_label_area_size(50)
        .build_cartesian_2d(0.0..fs / 2.0, 0.0..y_max)?;

    chart.configure_mesh()
        .x_desc("Frequency [Hz]")
        .y_desc("Power")
        .draw()?;

    chart.draw_series(LineSeries::new(
        freqs.iter().copied().zip(powers.iter().copied()),
        &RED,
    ))?;

    root.present()?;
    Ok(())
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let n = 1024;
    let fs = 1000.0; // サンプリング周波数 1000Hz
    let dt = 1.0 / fs;

    // 1. 信号の生成(50Hz + 120Hz)
    let t = time_grid(n, dt);
    let mut signal = synthetic_signal(&t);

    // 2. 窓関数の適用(Hann窓)
    let window = hann_window(n);
    apply_real_window(&mut signal, &window);

    // 3. FFTの実行
    fft_in_place(&mut signal);

    // 4. 結果の解析(パワースペクトルの計算)
    let (freqs, powers) = positive_frequency_power(&signal, fs);

    // 5. 可視化
    plot_power_spectrum(&freqs, &powers, fs, "spectrum.png")?;
    println!("spectrum.png を生成しました。");

    Ok(())
}

スペクトル解析の結果

まとめ

  • FFTのインデックス は周波数 に対応する。
  • データの端点による誤差(スペクトル漏れ)を抑えるには窓関数が不可欠。
  • ndarrayのベクトル演算を活用することで、信号処理のコードを簡潔かつ効率的に記述できる。
  • パワースペクトルを可視化することで、信号に含まれる支配的な周波数成分を一目で特定できる。

参考リンク


本章はこれで終わりです。次は常微分方程式に進みましょう。

Last change: , commit: 991b48c

常微分方程式

Important

この章を読む前に

この章を読むには、以下の章を先に読んでおく必要があります。

物理法則の多くは、時間や空間に対する変化率(微分)を含む微分方程式 (Differential Equations) として記述されます。

本章では、時間変数 の関数である に対する常微分方程式 (Ordinary Differential Equation, ODE) の初期値問題を数値的に解く手法を学びます。

本章の構成

  1. オイラー法 最も単純な解法であるオイラー法を通じて、微分方程式を数値的に解く(時間発展させる)基本的な考え方を学びます。

  2. ルンゲ=クッタ法 実用的な標準手法である4次のルンゲ=クッタ法 (RK4) を学びます。精度と計算コストのバランスが良く、最も広く使われている手法の一つです。

  3. 適応型刻み幅制御 誤差の大きさに応じて時間刻み を自動的に調整し、効率よく高精度な解を得る手法(ルンゲ=クッタ=フェールベルグ法など)を紹介します。

  4. 境界値問題 初期値問題とは異なり、区間の両端で条件が与えられる問題(例:両端が固定された弦の振動など)を解く「シューティング法」について触れます。

作業テーマ

作業ディレクトリは ~/rust-computational-physics-work/ からの相対パスです。 ユニットテストで確認する具体的なケースは、解析解、境界条件、許容誤差の観点からAI coding agentと相談して決めます。

テーマ作業ディレクトリ構造化するコードユニットテスト演習拡張演習
オイラー法ode/eulereuler_step, integrate, max_error指数関数の解析解と比較する刻み幅ごとの誤差をCSVに出力する
ルンゲ=クッタ法ode/rk4rk4_step, integrate, state_normRK4の刻み幅依存を確認するEuler法との誤差比較を追加する
適応型刻み幅ode/adaptive-steprk_embedded_step, accept_step, next_dt許容誤差を変えたときの結果を確認するrejected step数をmetadataに保存する
境界値問題ode/boundary-valueshoot, boundary_residual, bisect_slope線形な境界値問題で既知解と比較する初期推定を変えた収束比較を追加する

検証と実装の観点

常微分方程式の計算では、時間刻みを変えたときの収束性と、物理量の保存性を 分けて確認します。短時間で精度が高くても、長時間で保存量が大きく漂う場合があります。

  • 解析解が分かる指数関数や調和振動子で、刻み幅依存の誤差を確認する。
  • state、微分方程式、1 step更新、出力処理を関数やmoduleで分ける。
  • energy、norm、massなど、問題に応じた保存量や単位を確認する。
  • initial condition、time span、step size、toleranceを結果と一緒に保存する。

オイラー法

Note

本節のポイント

  • 常微分方程式の最も基本的な解法であるオイラー法の原理を理解する。
  • 1次精度手法の誤差の蓄積(大域誤差)について学ぶ。
  • 2階微分方程式を連立1階微分方程式に帰着させる手法を習得する。

オイラー法 (Euler’s Method) は、常微分方程式の数値解法の中で最も基本的かつ単純な手法です。実用的な精度は低いものの、微分方程式を数値的に解く(時間発展させる)ための基礎的な概念を理解するために不可欠です。

原理

1階の常微分方程式の初期値問題を考えます。

微分の定義により、時間刻み幅 が十分に小さければ、導関数は前方差分で近似できます。

これを について解くと、現在の状態 から次の時刻の状態 を求める更新式(漸化式)が得られます。

Euler Method

幾何学的には、「現在の点 における接線の傾き に沿って、時間 だけ直線的に進む」ことを意味します。このため、関数の曲率が大きい場合、ステップを進めるごとに真の解から大きく外れていくことになります。

誤差の性質

オイラー法の誤差には、1ステップごとに発生する 局所誤差 と、計算終了までに蓄積される 大域誤差 があります。

  • 局所誤差: テイラー展開を の項で打ち切っているため、 となります。
  • 大域誤差: 計算ステップ数は に比例するため、誤差の蓄積により となります。

このように、全体の誤差が の1乗に比例する手法を 1次精度(First-order accuracy) と呼びます。刻み幅 を半分にしても、誤差は半分にしかなりません。実用的な科学技術計算では、より高次(通常は4次以上)の手法が求められます。

Rustによる実装

まずは、1変数の微分方程式を解くプログラムを実装してみましょう。 例として、以下の放射性崩壊などを記述する方程式を考えます。

解析解は です。

/// オイラー法による1ステップの更新
fn euler_step<F>(x: f64, t: f64, h: f64, f: F) -> f64
where
    F: Fn(f64, f64) -> f64,
{
    x + h * f(t, x)
}

fn euler_integrate<F>(x0: f64, t0: f64, t_max: f64, h: f64, f: F) -> f64
where
    F: Fn(f64, f64) -> f64 + Copy,
{
    let mut t = t0;
    let mut x = x0;

    while t < t_max {
        let step_h = if t + h > t_max { t_max - t } else { h };
        x = euler_step(x, t, step_h, f);
        t += step_h;
    }

    x
}

fn main() {
    let f = |_t: f64, x: f64| -x;
    let x0 = 1.0;
    let t_max = 2.0;

    println!(
        "{:<5} {:<15} {:<15} {:<15}",
        "h", "Numerical", "Exact", "Error"
    );
    println!("{}", "-".repeat(55));

    for &h in &[0.4, 0.2, 0.1, 0.05] {
        let x = euler_integrate(x0, 0.0, t_max, h, f);
        let exact = (-t_max).exp();
        println!(
            "{:<5.2} {:<15.8} {:<15.8} {:<15.2e}",
            h,
            x,
            exact,
            (x - exact).abs()
        );
    }
}

実行結果:

h     Numerical       Exact           Error
-------------------------------------------------------
0.40  0.07776000      0.13533528      5.76e-2
0.20  0.08589935      0.13533528      4.94e-2
0.10  0.12157665      0.13533528      1.38e-2
0.05  0.12851216      0.13533528      6.82e-3

を半分にするごとに、誤差もおよそ半分になっていることが確認できます。これが1次精度の特徴です。

2階微分方程式への適用(連立化)

ニュートンの運動方程式 のような2階微分方程式は、速度 を導入することで、2つの連立1階微分方程式に書き換えて解くことができます。

これをオイラー法で書くと:

一般に、多変数あるいは高階の微分方程式は、状態をベクトル として扱うことで、単一のベクトル微分方程式 として統一的に記述できます。これについては次節のルンゲ=クッタ法で詳しく扱います。

まとめ

  • オイラー法は、微分を前方差分で近似する最も単純な解法。
  • 1次精度 ( )であり、実用的な精度を得るには非常に小さな刻み幅が必要。
  • 2階以上の微分方程式は、変数を増やすことで1階の連立方程式に変換できる。

次節では、オイラー法の精度を大幅に改善したルンゲ=クッタ法を学びます。

Last change: , commit: ec78068

ルンゲ=クッタ法

Note

本節のポイント

  • 科学技術計算の標準手法である4次のルンゲ=クッタ法(RK4)のアルゴリズムを理解する。
  • ndarrayを用いて、連立微分方程式をベクトル形式で効率的に解く実装方法を学ぶ。
  • 4次精度による圧倒的な誤差減少の効果を体感する。

ルンゲ=クッタ法(Runge-Kutta Methods) は、オイラー法よりも高精度に微分方程式を解くための一連の手法です。その中でも特に、4次のルンゲ=クッタ法(RK4) が精度と計算コストのバランスが非常に良く、実用上の標準として広く使われています。

原理:4次のルンゲ=クッタ法(RK4)

オイラー法が区間の始点での傾きだけを使ったのに対し、RK4では区間内の4点の傾きを計算し、それらを加重平均して次の値を決定します。 ベクトル形式の微分方程式 に対して、時間刻み での更新式は以下の通りです。

幾何学的意味

  1. : 始点での傾き(オイラー法と同じ)。
  2. : を使って中点へ進み、そこでの傾き。
  3. : を使って中点へ進み(修正)、そこでの傾き。
  4. : を使って終点へ進み、そこでの傾き。

最後にこれらを の重みで平均します。これにより、テイラー展開の4次の項までが一致し、大域誤差は (4次精度) となります。これは、刻み幅 を半分にすると、誤差が に激減することを意味します。

Rustによる実装(ndarrayの活用)

連立微分方程式(多変数)を扱う場合、ndarray入門で学んだndarrayを使うと、数学的なベクトル演算をそのままコードに落とし込めるため、非常に見通しが良くなります。 例として、単振動(調和振動子)の方程式を解いてみましょう。

これを連立化すると、状態ベクトル に対して以下のようになります。

use ndarray::{Array1, arr1};

/// 4次のルンゲ=クッタ法による1ステップの更新
fn rk4_step<F>(state: &Array1<f64>, t: f64, h: f64, f: &F) -> Array1<f64>
where
    F: Fn(f64, &Array1<f64>) -> Array1<f64>,
{
    let k1 = f(t, state);
    let k2 = f(t + h * 0.5, &(state + &k1 * (h * 0.5)));
    let k3 = f(t + h * 0.5, &(state + &k2 * (h * 0.5)));
    let k4 = f(t + h, &(state + &k3 * h));

    state + (&k1 + &k2 * 2.0 + &k3 * 2.0 + &k4) * (h / 6.0)
}

fn integrate_rk4<F>(
    state0: &Array1<f64>,
    t0: f64,
    t_max: f64,
    h: f64,
    f: &F,
) -> Array1<f64>
where
    F: Fn(f64, &Array1<f64>) -> Array1<f64>,
{
    let mut t = t0;
    let mut state = state0.clone();

    while t < t_max {
        // ステップ幅が余る場合の調整
        let step_h = if t + h > t_max { t_max - t } else { h };
        state = rk4_step(&state, t, step_h, f);
        t += step_h;
    }

    state
}

fn harmonic_oscillator(_t: f64, state: &Array1<f64>) -> Array1<f64> {
    let x = state[0];
    let v = state[1];
    arr1(&[v, -x])
}

fn main() {
    let x0 = arr1(&[1.0, 0.0]); // 初期条件: x=1, v=0
    let t_max = 2.0 * std::f64::consts::PI; // 1周期

    println!("{:<5} {:<15} {:<15}", "h", "Final x", "Error");
    println!("{}", "-".repeat(40));

    for &h in &[0.5, 0.25, 0.125, 0.0625] {
        let state = integrate_rk4(&x0, 0.0, t_max, h, &harmonic_oscillator);
        let exact = 1.0; // cos(2pi) = 1
        println!(
            "{:<5.3} {:<15.10} {:<15.2e}",
            h,
            state[0],
            (state[0] - exact).abs()
        );
    }
}

実行結果:

h     Final x         Error
----------------------------------------
0.500 0.9987316280    1.27e-3
0.250 0.9999579266    4.21e-5
0.125 0.9999986780    1.32e-6
0.062 0.9999999586    4.14e-8

を半分(1/2)にするごとに、誤差がおよそ 1/16 ずつ減少していることがわかります。オイラー法と比較して、少ない計算回数で驚異的な精度が得られることがRK4の強みです。

注意点と限界

RK4は非常に優秀ですが、万能ではありません

  1. 硬い方程式 (Stiff Equation): 時間スケールが大きく異なる現象(例:非常に速い振動とゆっくりの運動)が混在する系では、RK4は不安定になりやすく、ステップ幅 を極端に小さくする必要があります。このような場合は、陰的解法 (Implicit Method) などが用いられます。
  2. エネルギー保存: RK4はエネルギーを厳密には保存しません。長時間計算すると、軌道が徐々にズレていくことがあります。
コラム: エネルギー保存とシンプレクティック積分

天体力学や分子動力学など、長時間の安定性が求められるシミュレーションでは、精度の高いRK4よりも、エネルギー(ハミルトニアン)を一定の範囲に保つ性質を持つ シンプレクティック積分法 (Symplectic Integrator) が好まれることが多いです。これについてはシンプレクティック積分法で詳しく扱います。


次節では、計算精度を保ちながら効率よく計算するために、ステップ幅 を自動調整する方法を学びます。

Last change: , commit: ec78068

適応型刻み幅制御

Note

本節のポイント

  • 誤差の推定値に基づいて時間刻み幅 を自動的に調整する仕組みを理解する。
  • 埋め込み型ルンゲ=クッタ法(RKF45, DP5など)の原理を学ぶ。
  • 物理現象の変化の激しさに応じた効率的な計算手法を習得する。

これまで時間刻み幅 (ステップサイズ)は固定定数として扱ってきました。しかし、多くの物理現象において、変化の激しさは一定ではありません。

  • 変化が激しいとき: 天体が近接する場合や、爆発的な反応が起きる時などは、 を小さくして精度を確保したい。
  • 変化が緩やかなとき: 平衡状態に近い時などは、 を大きくして計算時間を短縮したい。

これを自動的に行うのが適応型刻み幅制御 (Adaptive Step Size Control) です。

原理:埋め込み型ルンゲ=クッタ法

適応型制御の最も一般的な方法は、2つの異なる次数の近似解を同時に計算し、その差を誤差の推定値として利用することです。これを「埋め込み型(Embedded)」と呼びます。

代表的なものに、ルンゲ=クッタ=フェールベルグ法(Runge-Kutta-Fehlberg, RKF45) や、その改良版である Dormand-Prince法(DP5) があります。

アルゴリズムの概要

例えばRKF45では、共通の中間変数( )を使い回しながら、以下の2つの解を計算します。

  1. 4次精度の近似解:
  2. 5次精度の近似解:

この2つの差 が、現在のステップにおける打ち切り誤差の目安になります。

ステップサイズの調整戦略

許容誤差を とします。

  1. の場合(成功): 精度は十分です。このステップを採用(通常はより精度の高い を使用)し、次のステップへ進みます。
  2. の場合(失敗): 誤差が大きすぎます。このステップを破棄し、 を小さくして計算をやり直します。

新しいステップ幅 は、一般に以下の式で決定します。

ここで は安全率(例:0.9)です。

Rustにおける実装: ode_solversクレート

適応型ステップ制御を自前で実装する場合、多くの係数(ブッチャー配列)を正確に記述する必要があり、バグの温床になりがちです。実務や高度な研究では、信頼性の高いライブラリを利用するのが賢明です。Rustではode_solversが広く使われています。

ライブラリの利用例

[dependencies]
ode_solvers = "0.6"
use ode_solvers::{Dopri5, System, Vector2};

type State = Vector2<f64>;

struct Oscillator;

impl System<f64, State> for Oscillator {
    fn system(&self, _t: f64, y: &State, dy: &mut State) {
        // 単振動: dx/dt = v, dv/dt = -x
        dy[0] = y[1];
        dy[1] = -y[0];
    }
}

fn oscillator_initial_state() -> State {
    State::new(1.0, 0.0)
}

fn main() {
    let system = Oscillator;
    let y0 = oscillator_initial_state();
    let (t_start, t_end) = (0.0, 10.0);

    // Dormand-Prince 5(4) 法を使用
    let mut stepper = Dopri5::new(system, t_start, t_end, 0.1, y0, 1.0e-8, 1.0e-8);
    let res = stepper.integrate();

    if let Ok(stats) = res {
        println!(
            "Integration finished. Total steps: {}",
            stats.accepted_steps
        );
        let values = stepper.y_out();
        println!("Final state: {:?}", values.last().unwrap());
    }
}

コードの解説

  1. システムの定義: Systemトレイトを実装することで、解きたい微分方程式を定義します。systemメソッド内で、現在の状態yから微分値dyを計算します。
  2. ソルバーの初期化: Dopri5::newを使用してソルバーを生成します。
    • 0.1: 最初のステップ幅(その後自動調整されます)。
    • 1.0e-8, 1.0e-8: それぞれ 相対許容誤差 (Relative Tolerance)絶対許容誤差 (Absolute Tolerance) です。ソルバーは推定誤差がこの範囲内に収まるようにステップ幅を制御します。
  3. 計算の実行: stepper.integrate()を呼ぶことで、終点までの計算を一気に行います。
  4. 結果の取得: stepper.x_out()で時刻のリストを、stepper.y_out()で各時刻における状態ベクトルのリストを取得できます。

メリットとデメリット

特徴固定刻み幅 (Fixed Step)適応型刻み幅 (Adaptive Step)
使いやすさhの値を自分で決める必要がある許容誤差を指定するだけでhが自動決定される
計算効率常に一定の負荷必要な箇所にだけ計算資源を集中させるため効率的
精度保証計算が終わるまで不明指定した許容誤差内に収まるよう制御される
出力データ等間隔(プロットしやすい)不等間隔(補間が必要な場合がある)

まとめ

  • 適応型刻み幅制御は、誤差を推定しながらステップ幅を動的に変更する。
  • 埋め込み型手法を用いることで、少ない追加コストで誤差推定が可能。
  • 変化の激しい系や、長時間のシミュレーションにおいて、精度と速度を両立させるための必須技術である。

参考リンク


次節では、初期値問題とは異なる「境界値問題」について学びます。

Last change: , commit: 991b48c

境界値問題

Note

本節のポイント

  • 初期値問題(IVP)と境界値問題(BVP)の違いを理解する。
  • 境界値問題を初期値問題に帰着させる「シューティング法」の原理を学ぶ。
  • 二分法とニュートン法で学んだ「求根アルゴリズム」との密接な関連性を理解する。

これまで扱ってきた微分方程式は、ある時刻 での状態(位置や速度)がすべて分かっていて、そこから未来を予測する初期値問題 (Initial Value Problem, IVP) でした。

しかし、物理学では「始点と終点の位置が決まっている」ような問題も頻繁に現れます。これを境界値問題 (Boundary Value Problem, BVP) と呼びます。

境界値問題の例

  • 静的な弦のたわみ: 両端が固定された弦の形状。
  • 熱伝導: 棒の両端の温度が固定されている場合の内部温度分布。
  • 量子力学の束縛状態: 波動関数が無限遠でゼロになる(または境界でゼロになる)条件。

シューティング法 (Shooting Method)

境界値問題を解くための最も直感的な手法がシューティング法(射撃法) です。これは、境界値問題を「未知の初期条件を探す初期値問題」として解く手法です。

原理

2階微分方程式 において、以下の境界条件が与えられているとします。

この問題を初期値問題として解くには、初期速度 が必要ですが、これは与えられていません。

  1. 初期速度の値を と仮定し、初期値問題 まで解きます。
  2. における計算結果を とします。
  3. 目的の境界条件 との差(誤差)を計算します:
  4. この となるような を、二分法とニュートン法で学んだ求根アルゴリズム(二分法やニュートン法)を用いて探索します。

まさに「標的(境界条件)に当たるように、大砲の角度(初期速度)を調整しながら試射を繰り返す」イメージです。

Rustによる実装例

例として、単振動の方程式 において、境界条件 を満たす解をシューティング法で求めてみましょう。解析解は であり、初期速度は となるはずです。

use ndarray::{Array1, arr1};
use std::f64::consts::PI;

/// 4次のルンゲ=クッタ法による1ステップの更新
fn rk4_step<F>(state: &Array1<f64>, t: f64, h: f64, f: F) -> Array1<f64>
where
    F: Fn(f64, &Array1<f64>) -> Array1<f64>,
{
    let k1 = f(t, state);
    let k2 = f(t + h * 0.5, &(state + &k1 * (h * 0.5)));
    let k3 = f(t + h * 0.5, &(state + &k2 * (h * 0.5)));
    let k4 = f(t + h, &(state + &k3 * h));
    state + (&k1 + &k2 * 2.0 + &k3 * 2.0 + &k4) * (h / 6.0)
}

/// 初期値問題 (IVP) として t0 から t1 まで積分し、終端の状態を返す
fn solve_ivp(v0: f64) -> f64 {
    let system = |_t: f64, state: &Array1<f64>| arr1(&[state[1], -state[0]]);
    let mut state = arr1(&[0.0, v0]); // x(0)=0, v(0)=v0
    let mut t = 0.0;
    let t1 = PI / 2.0;
    let h = 0.01;

    while t < t1 {
        let step_h = if t + h > t1 { t1 - t } else { h };
        state = rk4_step(&state, t, step_h, system);
        t += step_h;
    }
    state[0] // 終端位置 x(t1) を返す
}

fn main() {
    let target_x1 = 1.0; // 目標: x(pi/2) = 1
    let tolerance = 1e-8;

    // 二分法による初期速度 v0 の探索
    let mut low = 0.0;
    let mut high = 2.0;
    let mut v0 = (low + high) / 2.0;

    println!("{:<5} {:<15} {:<15}", "Iter", "v0 (Guess)", "x(pi/2) Error");
    println!("{}", "-".repeat(40));

    for i in 0..50 {
        let x_final = solve_ivp(v0);
        let error = x_final - target_x1;

        println!("{:<5} {:<15.8} {:<15.2e}", i, v0, error);

        if error.abs() < tolerance {
            break;
        }

        if error < 0.0 {
            low = v0;
        } else {
            high = v0;
        }
        v0 = (low + high) / 2.0;
    }

    println!("\n結果: 求める初期速度 v(0) = {:.8}", v0);
}

シューティング法の限界

シューティング法は直感的で実装も比較的容易ですが、以下のような課題があります。

  • 不安定性: 微分方程式が「硬い(stiff)」場合、初期値 の極めて微小な変化が、終端 で爆発的な差となって現れることがあり、求根アルゴリズムが収束しなくなります。
  • 多変数の困難さ: 高次元の境界値問題では、探索すべき初期パラメータが増え、求根が非常に困難になります。

このような場合、領域全体を格子に分割して一気に解く有限差分法 (Finite Difference Method) や、変分法に基づいた手法が用いられます。これらについては、偏微分方程式で詳しく扱います。

参考リンク


常微分方程式では、物理現象の記述に欠かせない数値解法を学びました。偏微分方程式からは、空間的な広がりを持つ現象へと進んでいきます。

Last change: , commit: 991b48c

偏微分方程式

Important

この章を読む前に

この章を読むには、以下の章を先に読んでおく必要があります。

現実の物理現象の多くは、時間だけでなく空間( )にも依存する偏微分方程式 (Partial Differential Equation, PDE) として記述されます。

偏微分方程式の分類

物理学で現れる主要なPDEは、数学的な性質から以下の3つに分類されます。

  1. 放物型 (Parabolic)

    • 例:拡散方程式(熱伝導方程式)
    • 時間とともに情報が広がり、平滑化される現象。
  2. 双曲型 (Hyperbolic)

    • 例:波動方程式
    • 情報が波として有限の速度で伝播する現象。
  3. 楕円型 (Elliptic)

    • 例:ポアソン方程式
    • 静的な場の分布を記述し、領域全体の境界条件によって解が決定される。

本章の構成

  1. 差分法の基礎 偏微分を格子点上の差分で近似する基本的な考え方と、安定性の概念について学びます。

  2. 拡散方程式 時間発展を追う放物型方程式の解法(陽解法と陰解法)を学びます。

  3. 波動方程式 波の伝播をシミュレーションする双曲型方程式の扱い方を学びます。

  4. 楕円型方程式 静的なポテンシャル分布を求める手法(反復法など)を学びます。

作業テーマ

作業ディレクトリは ~/rust-computational-physics-work/ からの相対パスです。 ユニットテストで確認する具体的なケースは、解析解、境界条件、許容誤差の観点からAI coding agentと相談して決めます。

テーマ作業ディレクトリ構造化するコードユニットテスト演習拡張演習
差分法の基礎pde/finite-differenceidx, central_diff, laplacian_1d小さい格子でstencilを手計算と比較する境界条件を切り替えられる形にする
拡散方程式pde/diffusionstep_ftcs, check_cfl, mass安定条件と質量保存の目安を確認する時間発展をCSVに出力する
波動方程式pde/wavestep_wave, energy, apply_boundary小さい格子で初期ステップを確認する反射境界と固定境界を比較する
楕円型方程式pde/ellipticjacobi_step, residual, apply_boundary既知境界の小さい問題で残差を確認するresidual履歴をCSVに出力する

検証と実装の観点

偏微分方程式では、数値スキームだけでなく、格子、境界条件、添字の対応が 正しいかを確認します。境界の1点のずれが、安定性や保存量に大きく影響します。

  • 小さい格子で、stencil、boundary、linear indexを手計算と比較する。
  • 拡散方程式や波動方程式では、CFL条件と刻み幅依存性を確認する。
  • 楕円型方程式では、反復回数だけでなくresidualと境界条件を確認する。
  • grid shape、spacing、time step、boundary conditionをmetadataとして保存する。

差分法の基礎

Note

本節のポイント

  • 偏微分方程式を離散化する基本的な考え方である差分法を理解する。
  • 1階および2階偏微分の差分近似式を導出する。
  • 計算の安定性を左右するCFL条件の概念を学ぶ。
  • 境界条件の種類と実装上の工夫(ゴーストセル)を知る。

偏微分方程式をコンピュータで解くために、連続的な空間や時間を格子状に分割して離散化する手法を差分法 (Finite Difference Method, FDM) と呼びます。

離散化の表記

空間 を刻み幅 で、時間 を刻み幅 で分割します。 点 における関数の値を と表記します。

偏微分の差分近似

数値微分と数値積分で学んだ数値微分の手法を、偏微分にも適用します。これらの公式は、テイラー展開から導くことができます。

における関数 だけずれた点でテイラー展開すると:

1階偏微分

上の第1式から について解くと、前進差分 が得られます:

第1式から第2式を引くと、中心差分 が得られます:

中心差分の方が誤差のオーダーが小さく、精度が良いことがわかります。

2階偏微分

第1式と第2式を足し合わせると、 の項が消え、2階微分(ラプラシアン)の近似式が得られます:

これを多次元に拡張すると:

  • 1次元の場合:
  • 2次元の場合(5点差分近似):
(ここで と仮定)

5-point Stencil

このように、ある点でのラプラシアンを計算するために周囲の点を利用する形をステンシル (Stencil) と呼びます。上の図は2次元の5点ステンシルを表しており、中心点(赤)の更新に上下左右の4点(青)が関わっていることを示しています。

安定性と収束性

PDEの数値計算において最も重要な概念の一つが安定性です。 不適切な の組み合わせを選ぶと、計算結果が指数関数的に増大して破綻(発散)してしまいます。

CFL条件 (Courant-Friedrichs-Lewy Condition)

波動の伝播などを陽的な差分スキームで解く際、「数値的な情報の伝達速度が、物理的な波の速度を上回っていなければならない」という条件です。 1次元波動方程式の標準的な中心差分スキームでは、波の速度を とすると、以下の条件が必要になります。

この条件を満たさない場合、計算は不安定になります。具体的な上限値は方程式、空間次元、離散化スキームによって変わります。

境界条件

PDEを解くには、領域の端での振る舞いを指定する必要があります。

  • ディリクレ境界条件: 境界での値を固定する(例:端の温度を 度に保つ)。
  • ノイマン境界条件: 境界での微分係数(フラックス)を固定する(例:断熱条件 )。
  • 周期境界条件: 右端と左端がつながっているとみなす。
補足: ノイマン境界条件の実装(ゴーストセル)

「端での傾き(微分)が0」という条件 を差分法で実装する場合、単純な前進・後退差分よりも、中心差分を使った方が精度が良くなります。

しかし、境界点(例えば )で中心差分を取ろうとすると、領域外の点 が必要になります。

このように、計算のためだけに導入する仮想的な格子点をゴーストセル (Ghost Cell) と呼びます。 拡散方程式などの更新式において、 を更新する際に が出てきたら、それを で置き換えることで、自然にノイマン条件(断熱条件)を組み込むことができます。

Rustによるデータの表現と計算

偏微分方程式では大量の格子点データを扱うため、ndarrayライブラリを活用します。単なる配列の確保だけでなく、スライス演算 を利用すると、差分計算をループなしで数学の公式に近い形で記述できます。 例として、関数 の1階微分を中心差分で求めてみましょう。

use ndarray::{Array1, s};
use std::f64::consts::PI;

fn centered_first_derivative(u: &Array1<f64>, dx: f64) -> Array1<f64> {
    let nx = u.len();

    // du/dx[i] = (u[i+1] - u[i-1]) / (2*dx)
    // s![2..] は 2番目以降、s![..nx-2] は 最後から2つ目までを指す
    (&u.slice(s![2..]) - &u.slice(s![..nx - 2])) / (2.0 * dx)
}

fn main() {
    let nx = 100;
    let x_min = 0.0;
    let x_max = 2.0 * PI;
    let dx = (x_max - x_min) / (nx - 1) as f64;

    // 1. 格子点の生成 (linspace)
    let x = Array1::linspace(x_min, x_max, nx);

    // 2. 関数の値を計算: u = sin(x)
    let u = x.mapv(|v| v.sin());

    // 3. 中心差分による数値微分を一括計算
    let du_dx_num = centered_first_derivative(&u, dx);

    // 4. 解析解 (cos(x)) との比較
    let x_inner = x.slice(s![1..nx - 1]);
    let du_dx_exact = x_inner.mapv(|v| v.cos());

    // 中央付近(x = PI)の結果を表示
    let mid = nx / 2;

    println!("x = {:.4}", x_inner[mid]);
    println!("Numerical: {:.6}", du_dx_num[mid]);
    println!("Exact:     {:.6}", du_dx_exact[mid]);
    println!(
        "Error:     {:.2e}",
        (du_dx_num[mid] - du_dx_exact[mid]).abs()
    );
}
x = 3.2368
Numerical: -0.994804
Exact:     -0.995472
Error:     6.68e-4

ndarray スライスのポイント

  • s![2..]s![..nx-2]: インデックスをずらした「ビュー」を作成しています。これらを引き算することで、全格子点に対するu[i+1] - u[i-1]を一気に計算できます。
  • 効率: この書き方は内部で最適化され、明示的なループを書くのと同等、あるいはそれ以上の速度で動作します。また、多次元配列(2D/3D)への拡張も容易です。

まとめ

  • 差分法は、微分を格子点上の値の差で近似する手法。
  • 空間には中心差分、時間には前進差分(または後退差分)がよく用いられる。
  • CFL条件などの安定性条件を満たすように刻み幅 を選ぶ必要がある。
  • 境界条件にはディリクレ、ノイマン、周期境界などがある。

参考リンク


次節では、具体例として拡散方程式を解いてみましょう。

Last change: , commit: 991b48c

拡散方程式

Note

本節のポイント

  • 拡散方程式(熱伝導方程式)の物理的意味を理解する。
  • 陽解法(FTCS法)による離散化と、その安定性条件(フォン・ノイマンの安定性解析)を学ぶ。
  • ndarrayを用いた効率的な空間格子の扱い方を習得する。

熱の伝導や粒子の拡散を記述する拡散方程式(熱伝導方程式) を扱います。

ここで は拡散係数です。

陽解法 (Explicit Method: FTCS)

最も単純な手法は、時間について前進差分、空間について中心差分を用いる FTCS (Forward-Time Central-Space) 法です。

拡散方程式 に、前節で学んだ差分近似を代入します。

これを について解くと、現在の時刻 の値から次の時刻 の値を直接計算できる更新式が得られます。

ここで、 です。

FTCS Stencil

安定性条件

FTCS法が安定であるためには、拡散数 が以下の条件を満たす必要があります。

この条件は、更新式を と書き直すと直感的に理解できます。これは周囲の点との加重平均の形をしていますが、 になると中央の点 の重み が負になってしまいます。物理的には「熱が隣へ移動しすぎて、元の場所が周囲より冷たくなってしまう」という不自然な逆転現象が起き、それが増幅されて計算が爆発(発散)するのです。

陰解法(Implicit Method)

安定性の制約を回避するために、次の時刻 の値を用いて微分を近似する陰解法が使われます。特に有名なのが クランク・ニコルソン法(Crank-Nicolson method) です。

線形拡散方程式に対しては、クランク・ニコルソン法は時間刻みに関して無条件安定です。ただし、 を大きくしすぎると精度が落ちたり、初期条件や境界条件によっては減衰する数値振動が目立つことがあります。また、各ステップで連立一次方程式を解く必要があります。これについては連立一次方程式で学んだLU分解などの手法が応用できます。

Rustによる実装 (陽解法)

ndarrayを用いて、1次元の拡散方程式を解くプログラムを実装してみましょう。

use ndarray::Array1;

fn diffusion_number(d_coeff: f64, dt: f64, dx: f64) -> f64 {
    d_coeff * dt / (dx * dx)
}

fn point_source_initial(nx: usize, amplitude: f64) -> Array1<f64> {
    let mut u = Array1::<f64>::zeros(nx);
    u[nx / 2] = amplitude;
    u
}

fn ftcs_step(u: &Array1<f64>, r: f64) -> Array1<f64> {
    let nx = u.len();
    let mut u_next = Array1::<f64>::zeros(nx);

    // 境界を除く内部点を更新
    for i in 1..nx - 1 {
        u_next[i] = u[i] + r * (u[i + 1] - 2.0 * u[i] + u[i - 1]);
    }

    // 境界条件 (固定境界: ディリクレ条件)
    u_next[0] = 0.0;
    u_next[nx - 1] = 0.0;

    u_next
}

fn evolve_diffusion(
    mut u: Array1<f64>,
    r: f64,
    nt: usize,
    sample_interval: usize,
) -> (Array1<f64>, Vec<(usize, f64)>) {
    let center = u.len() / 2;
    let mut samples = Vec::new();

    for n in 0..nt {
        u = ftcs_step(&u, r);

        if n % sample_interval == 0 {
            samples.push((n, u[center]));
        }
    }

    (u, samples)
}

fn main() {
    let nx = 50; // 空間分割数
    let nt = 500; // 時間ステップ数
    let dx = 1.0;
    let dt = 0.2;
    let d_coeff = 1.0; // 拡散係数

    let r = diffusion_number(d_coeff, dt, dx);
    println!("拡散数 r = {:.3}", r);

    if r > 0.5 {
        eprintln!("Warning: 安定性条件 (r <= 0.5) を満たしていません!");
    }

    // 初期状態: 中央に熱源がある(デルタ関数的な初期分布)
    let u0 = point_source_initial(nx, 100.0);
    let (_u_final, samples) = evolve_diffusion(u0, r, nt, 100);

    for (step, center_value) in samples {
        println!("Step {}: u[center] = {:.4}", step, center_value);
    }
}
拡散数 r = 0.200
Step 0: u[center] = 60.0000
Step 100: u[center] = 6.2727
Step 200: u[center] = 4.4478
Step 300: u[center] = 3.6347
Step 400: u[center] = 3.1458

結果の解釈

中央の温度(u[center])が、ステップが進むにつれて急速に低下していることが分かります。これは、初期に一点に集中していた熱が周囲へと「拡散」し、分布が平滑化されている物理現象を反映しています。 また、今回は両端の温度を に固定しているため、熱は次第に境界から外部へと逃げていき、十分な時間が経過すれば領域全体の温度は に収束します。計算結果の減衰速度が後半になるほど緩やかになるのは、温度勾配が小さくなるにつれて拡散のドライビングフォースが弱まるためです。

まとめ

  • 拡散方程式 は、時間1階・空間2階の偏微分方程式。
  • FTCS法(陽解法) は実装が容易だが、安定性条件 という厳しい制約がある。
  • クランク・ニコルソン法などの陰的手法 を用いると安定性の制約を大きく緩和できるが、精度のための時間刻み選択と連立一次方程式の求解が必要になる。

参考リンク


次節では、波の伝播を扱う波動方程式について学びます。

Last change: , commit: 991b48c

波動方程式

Note

本節のポイント

  • 時間2階微分を含む波動方程式の離散化手法を理解する。
  • 3つの時刻(過去・現在・未来)を保持するバッファの管理方法を学ぶ。
  • 初期速度条件を考慮した最初のステップの計算方法を習得する。
  • CFL条件による安定性の制約を確認する。

波の伝搬(弦の振動、音波、電磁波など)を記述する波動方程式を扱います。

ここで は波の伝播速度です。拡散方程式とは異なり、時間に2階微分が含まれていることが大きな特徴です。

離散化

時間、空間ともに中心差分を用います。

これを について解くと:

ここで はクーラン数です。

Wave Equation Stencil

この式からわかるように、次の時刻の状態 を決めるには、現在( )と1つ前( )の2つの時刻の情報が必要です。

初期条件と最初のステップ

2階微分方程式なので、初期状態として位置 だけでなく、初期速度 も必要です。

最初のステップ( )を計算する際、更新式には という仮想的な過去の値が必要になります。これを初期速度 を用いた中心差分から推定します。

これを時刻 における更新式に代入すると:

これが最初のステップ専用の更新式です。初期速度 の場合は、第2項が消えてさらにシンプルになります。

安定性条件(CFL条件)

波動の数値計算においては、差分法の基礎でも触れたCFL条件が重要です。この節で使う1次元の中心差分スキームでは、以下の条件が安定性の目安になります。

これが満たされない場合、波が不自然に増幅されて発散します。

なぜ不安定になるのか?

この条件は、「情報の伝わる速さ」 の観点から直感的に理解できます。

  1. 物理的な速さ: 物理現象として波が伝わる速さは です。1ステップ( )の間に、波は だけ進みます。
  2. 計算上の速さ: 一方、この差分スキームにおいて情報が伝わる速さは です(1ステップで隣の格子点 までしか情報が伝わらない)。

もし (つまり )になると、物理的な波のスピードが計算格子の上で情報が伝わるスピードを追い越してしまいます。数値計算が物理現象の進展に追いつけなくなり、計算格子が「本来あるはずの波の姿」を正しく表現できず、数値的な誤差が指数関数的に増大して爆発(発散)を引き起こすのです。

幾何学的には、点 物理的な依存領域(その点に影響を与えうる過去の範囲)が、スキームの数値的な依存領域(計算に使用する格子点の範囲)の外側にはみ出してしまうことに対応します。

Rustによる実装

両端固定の弦の振動をシミュレーションする例です。ndarray::Array1を用いて、3つの時間ステップを管理します。

use ndarray::Array1;

fn cfl_number(wave_speed: f64, dt: f64, dx: f64) -> f64 {
    wave_speed * dt / dx
}

fn gaussian_pulse(nx: usize, dx: f64, sigma: f64) -> Array1<f64> {
    let mut u = Array1::<f64>::zeros(nx);
    let center = (nx / 2) as f64 * dx;

    for i in 0..nx {
        let x = i as f64 * dx;
        u[i] = (-(x - center).powi(2) / (2.0 * sigma.powi(2))).exp();
    }

    u
}

fn first_wave_step(u_curr: &Array1<f64>, c: f64) -> Array1<f64> {
    let nx = u_curr.len();
    let mut u_next = Array1::<f64>::zeros(nx);

    // 初期速度 0 と仮定
    let c2 = c * c;
    for i in 1..nx - 1 {
        u_next[i] = u_curr[i] + 0.5 * c2 * (u_curr[i + 1] - 2.0 * u_curr[i] + u_curr[i - 1]);
    }
    // 境界条件 (固定端)
    u_next[0] = 0.0;
    u_next[nx - 1] = 0.0;

    u_next
}

fn wave_step(u_prev: &Array1<f64>, u_curr: &Array1<f64>, c: f64) -> Array1<f64> {
    let nx = u_curr.len();
    let mut u_next = Array1::<f64>::zeros(nx);
    let c2 = c * c;

    for i in 1..nx - 1 {
        u_next[i] = 2.0 * u_curr[i] - u_prev[i]
            + c2 * (u_curr[i + 1] - 2.0 * u_curr[i] + u_curr[i - 1]);
    }

    // 境界条件
    u_next[0] = 0.0;
    u_next[nx - 1] = 0.0;

    u_next
}

fn evolve_wave(
    u0: Array1<f64>,
    c: f64,
    nt: usize,
    sample_interval: usize,
) -> (Array1<f64>, Vec<(usize, f64)>) {
    let center = u0.len() / 2;
    let mut samples = Vec::new();

    let mut u_prev = u0;
    let mut u_curr = first_wave_step(&u_prev, c);

    for n in 2..nt {
        let u_next = wave_step(&u_prev, &u_curr, c);

        if n % sample_interval == 0 {
            samples.push((n, u_next[center]));
        }

        u_prev = u_curr;
        u_curr = u_next;
    }

    (u_curr, samples)
}

fn main() {
    let nx = 100;
    let nt = 300;
    let dx = 0.1;
    let dt = 0.05;
    let v = 1.0; // 波の速度

    // CFL条件のチェック
    let c = cfl_number(v, dt, dx);
    println!("CFL数 = {:.3}", c);
    if c > 1.0 {
        eprintln!("Warning: 不安定な条件 (CFL > 1) です!");
    }

    // 1. 初期条件の設定 (t=0)
    // ガウス波束を中心に配置
    let u0 = gaussian_pulse(nx, dx, 1.0);

    // 2. 最初のステップと時間発展
    let (_u_final, samples) = evolve_wave(u0, c, nt, 50);

    for (step, center_value) in samples {
        println!("Step {}: u[center] = {:.4}", step, center_value);
    }
}
CFL数 = 0.500
Step 50: u[center] = 0.0437
Step 100: u[center] = -0.0000
Step 150: u[center] = -0.0583
Step 200: u[center] = -0.9909
Step 250: u[center] = -0.0337

結果の解釈

中央の変位(u[center])が、時間の経過とともに正から負へと大きく変動していることが分かります。これは、中央から始まった波のパルスが両端(固定端)で反射し、戻ってきて再び中央で重なり合う往復運動を再現しています。 拡散方程式の結果と比較すると、値が一方的に小さくなるのではなく、変位が維持されながら周期的に変化している点が大きな違いです。これは、波動方程式がエネルギーを散逸させずに伝播・保持する物理的性質を、数値計算が正しく捉えていることを示しています。

まとめ

  • 波動方程式は、時間2階・空間2階の偏微分方程式であり、情報の伝播を記述する。
  • 数値解法には、過去・現在・未来の3つの時刻の格子点データが必要。
  • 初期速度条件を適切に処理するために、最初の1ステップ目は特別な更新式を用いる。
  • 安定性のためには CFL条件( ) を厳守する必要がある。

参考リンク


次節では、静的な場の分布を求める楕円型方程式について学びます。

Last change: , commit: 991b48c

楕円型方程式

Note

本節のポイント

  • 静的な場の分布を記述する楕円型方程式(ポアソン方程式・ラプラス方程式)の性質を学ぶ。
  • 境界値問題としての解法である「反復法」の原理を理解する。
  • ヤコビ法、ガウス=ザイデル法、SOR法の違いを学ぶ。
  • ndarray::Array2を用いた2次元格子の効率的な扱い方を習得する。

静的な電位分布や定常的な温度分布を記述する、時間に依存しない方程式を扱います。

  • ラプラス方程式:
  • ポアソン方程式:

2次元のポアソン方程式は以下の通りです。

離散化の導出

2次元のポアソン方程式 を考えます。空間刻みを として、中心差分による近似を代入します。

分子を整理すると:

これを について解くと、格子点 における値が周囲4点の平均(および源 )で表されることがわかります。

ラプラス方程式( )の場合、これは 「ある点での値は、その周囲の平均値に等しい」 という調和関数の性質をそのまま離散化したものに対応します。

反復法による解法

楕円型方程式は領域全体の境界条件によって解が決まるため、時間発展のように端から順番に計算することはできません。通常は、適当な初期推定値から始めて、上の関係式を満たすように値を修正していく反復法 が用いられます。

Iteration Methods Comparison

1. ヤコビ法(Jacobi Method)

古いステップ の値をすべて使って、新しいステップ の値を一斉に計算します。

プログラム上では、新旧2つの配列を用意する必要があります。収束は非常に低速です。

2. ガウス=ザイデル法(Gauss-Seidel Method)

計算が完了したばかりの最新の の値を、同じステップ内の後半の計算で直ちに利用します。

ヤコビ法よりも収束が速く、配列も1つで済むためメモリ効率が良いのが特徴です。

3. SOR法(Successive Over-Relaxation)

ガウス=ザイデル法で求まる修正量をさらに強調(オーバーリラクゼーション)させる手法です。 ガウス=ザイデル法による新しい値を とすると、実際の更新値を以下のように決めます。

ここで は加速パラメータです。

  • : ガウス=ザイデル法と同じ。
  • : 収束を加速させます(目標値へ向かう変化を「追い越す」ように多めに修正する)。

適切な (通常 程度)を選ぶことで、収束までの反復回数を劇的に減らすことができます。

Rustによる実装(ガウス=ザイデル法)

ndarray::Array2を用いて、2次元正方形領域におけるラプラス方程式 を解きます。 境界条件として、上辺を 、それ以外を とします(ディリクレ問題)。

use ndarray::Array2;

fn initialize_plate(n: usize, top_boundary: f64) -> Array2<f64> {
    // 2次元グリッドの初期化 (0.0)
    let mut phi = Array2::<f64>::zeros((n, n));

    // 境界条件の設定
    // 上辺 (y=0) を top_boundary に固定
    for x in 0..n {
        phi[[0, x]] = top_boundary;
    }
    // 左辺、右辺、下辺は 0.0 のまま

    phi
}

fn gauss_seidel_step(phi: &mut Array2<f64>) -> f64 {
    let n = phi.nrows();
    let mut max_diff = 0.0;

    // グリッド内部の更新 (y, x)
    for y in 1..n - 1 {
        for x in 1..n - 1 {
            let old_val = phi[[y, x]];

            // ガウス=ザイデル法: 最新の値をそのまま使って更新
            let new_val =
                0.25 * (phi[[y, x + 1]] + phi[[y, x - 1]] + phi[[y + 1, x]] + phi[[y - 1, x]]);

            phi[[y, x]] = new_val;

            let diff = (new_val - old_val).abs();
            if diff > max_diff {
                max_diff = diff;
            }
        }
    }

    max_diff
}

fn solve_laplace_gauss_seidel(
    mut phi: Array2<f64>,
    max_iter: usize,
    tolerance: f64,
    sample_interval: usize,
) -> (Array2<f64>, usize, bool, Vec<(usize, f64)>) {
    let mut samples = Vec::new();

    for iter in 0..max_iter {
        let max_diff = gauss_seidel_step(&mut phi);

        // 収束判定
        if max_diff < tolerance {
            return (phi, iter + 1, true, samples);
        }

        if iter % sample_interval == 0 {
            samples.push((iter, max_diff));
        }
    }

    (phi, max_iter, false, samples)
}

fn main() {
    let n = 50; // グリッドサイズ 50x50
    let max_iter = 10000;
    let tolerance = 1e-4; // 収束判定の閾値

    let phi0 = initialize_plate(n, 100.0);
    let (phi, iterations, converged, samples) =
        solve_laplace_gauss_seidel(phi0, max_iter, tolerance, 500);

    for (iter, max_diff) in samples {
        println!("Iter {}: max_diff = {:.6}", iter, max_diff);
    }
    if converged {
        println!("収束しました: 反復回数 {}", iterations);
    } else {
        println!("最大反復回数に達しました: 反復回数 {}", iterations);
    }

    // 結果の確認(中心付近の値)
    println!("phi[25, 25] = {:.2}", phi[[n / 2, n / 2]]);
}
Iter 0: max_diff = 33.333333
Iter 500: max_diff = 0.020671
Iter 1000: max_diff = 0.002597
Iter 1500: max_diff = 0.000332
収束しました: 反復回数 1793
phi[25, 25] = 24.12

結果の解釈

反復が進むにつれて、最大の変化量(max_diff)が着実に減少していることが分かります。これは、初期の適当な推定値が、周囲の格子点との平均関係を満たす「滑らかな解(調和関数)」へと一歩ずつ近づいているプロセスを表しています。 最終的に得られたphi[25, 25] = 24.12という値は、上辺( )からのポテンシャルが領域内部へと浸透し、他の三辺( )との境界条件を満たしながら定常状態に達した結果です。ラプラス方程式の解は「極大・極小を領域の内部に持たず、常に境界に依存する」という性質を持っていますが、この数値解もその物理的特徴を正しく再現しています。

まとめ

  • 楕円型方程式は時間に依存しない定常状態を記述し、境界条件によって領域全体の解が決定される。
  • 反復法を用いることで、離散化された代数方程式を数値的に解くことができる。
  • ガウス=ザイデル法SOR法は、ヤコビ法に比べて収束速度とメモリ効率の面で優れている。

本章はこれで終わりです。次はモンテカルロ法に進みましょう。

Last change: , commit: ec78068

モンテカルロ法

Note

本章のポイント

  • モンテカルロ法の理論的背景(大数の法則、中心極限定理)と誤差評価を理解する。
  • Rustのrandクレートを用いた高品質な乱数生成と、特定の分布への変換手法を習得する。
  • 重点サンプリングによる収束の加速(分散減少法)を学ぶ。
  • マルコフ連鎖モンテカルロ法(MCMC)の基礎アルゴリズムを理解する。

モンテカルロ法 (Monte Carlo method) は、乱数を用いた試行を繰り返すことで近似解を得る数値計算手法の総称です。その名は、カジノで有名なモナコの公国モンテカルロにちなんで命名されました。

モンテカルロ法の重要性と特徴

数値微分と数値積分で学んだような台形則やシンプソン則などの決定論的な積分手法は、低次元では非常に高精度ですが、次元 が増えると計算量が のように指数関数的に増加してしまいます(次元の呪い)。

一方、独立サンプルで分散が有限な場合、モンテカルロ法の標本平均の統計誤差は試行回数 に対して で減少します。この収束率自体には格子法のような の指数的な増加は現れません。ただし、分散の大きさ、適切なサンプリング分布、有効サンプル数は、次元 や被積分関数の性質に強く依存します。そのため、以下のような現代物理学の最前線では、単純サンプリングだけでなく重点サンプリングやMCMCが重要になります。

  • 多次元積分: 統計力学における配位空間積分や量子力学の経路積分。
  • 統計物理学: 磁性体の相転移現象(イジング模型など)のシミュレーション。
  • 素粒子物理学: 格子ゲージ理論における真空期待値の計算。

参考リンク

本章の構成

  1. 乱数生成 計算の基礎となる疑似乱数の生成原理と、特定の確率分布(正規分布・指数分布など)に従う乱数の作り方を学びます。

  2. モンテカルロ積分 一様乱数を用いた基本的な積分手法と、その誤差評価(中心極限定理)について学びます。また、rayonを用いた並列化による高速化も扱います。

  3. 重点サンプリング 関数の形状に合わせてサンプリング密度を調整し、計算効率を劇的に向上させる手法を学びます。

  4. マルコフ連鎖モンテカルロ法 (MCMC) 多変数・複雑な分布を扱うための強力な枠組みである、メトロポリス法などのアルゴリズムを学びます。

作業テーマ

作業ディレクトリは ~/rust-computational-physics-work/ からの相対パスです。 ユニットテストで確認する具体的なケースは、解析解、境界条件、許容誤差の観点からAI coding agentと相談して決めます。

テーマ作業ディレクトリ構造化するコードユニットテスト演習拡張演習
乱数生成monte-carlo/random-numbersmake_rng, sample_uniform, sample_normalseed固定で同じ列が出ることを確認する分布の平均と分散をCSVに出力する
モンテカルロ積分monte-carlo/integrationmc_integrate, mean, standard_error既知積分値と標準誤差を比較する複数seedの独立試行を追加する
重点サンプリングmonte-carlo/importance-samplingsample_proposal, weight, estimate重みつき推定量の平均を確認する一様サンプリングとの分散比較を追加する
MCMCmonte-carlo/mcmcpropose, accept, measure固定seedで受理率と平均を確認するburn-inとsampling intervalをmetadataに保存する

検証と実装の観点

モンテカルロ法では、1回の実行結果だけでは精度を判断できません。乱数seed、 試行回数、独立試行、誤差棒を記録し、再実行できる形で結果を残します。

  • seedを固定した再現実行と、seedを変えた独立試行の両方を確認する。
  • 既知の期待値や分布を使い、推定値と標準誤差を比較する。
  • 重点サンプリングでは、重みの定義と分散減少を確認する。
  • MCMCでは、burn-in、sampling interval、autocorrelationを確認する。

乱数生成

Note

本節のポイント

  • 疑似乱数生成器(PRNG)の特性と、物理計算における再現性の重要性を理解する。
  • randクレートを用いた一様乱数の生成手法を習得する。
  • 逆関数法やボックス=ミュラー法などの、任意の確率分布への変換原理を数学的に理解する。

モンテカルロ法の精度と信頼性は、使用する疑似乱数 (Pseudo-random numbers) の質に直接依存します。物理シミュレーションでは、巨大なサンプル数を扱うため、周期が長く、統計的な偏りが極めて少ない生成器が必要です。

Rustにおける乱数: randクレート

Rustの数値計算において、乱数はrandクレートとそのエコシステムを通じて提供されます。

依存関係

[dependencies]
rand = "0.10"
rand_chacha = "0.10" # 高品質な疑似乱数生成器
rand_distr = "0.6" # 特定の統計分布

物理計算では、標準的なStdRng以外にも、ChaCha アルゴリズムのような暗号学的にも安全な(=予測困難で統計的性質に優れた)生成器が好んで使われます。

物理計算と再現性

数値シミュレーションでは、バグの特定や結果の検証のために、同じ条件下で全く同じ乱数列を生成する 再現性(Reproducibility) が極めて重要です。

use rand::{RngExt, SeedableRng};
use rand_chacha::ChaCha8Rng;

fn fixed_random_values(seed: [u8; 32]) -> (f64, i32) {
    // シードから生成器を初期化
    let mut rng = ChaCha8Rng::from_seed(seed);

    // [0, 1) の範囲の f64 型の一様乱数を生成
    let x: f64 = rng.random();
    // 1 から 100 までの整数の範囲から一様乱数を生成
    let n: i32 = rng.random_range(1..=100);

    (x, n)
}

fn main() {
    // 32バイトのシード(種)を固定することで、常に同じ乱数列を得る
    let seed = [0u8; 32];
    let (x, n) = fixed_random_values(seed);

    println!("Fixed x: {}, n: {}", x, n);
}
Fixed x: 0.8369197568826472, n: 91
  • SeedableRng: シード値から生成器を決定論的に初期化するためのトレイトです。
  • random(): 対象となる型の全範囲(実数の場合は )から乱数を生成します。
  • random_range(range): 指定された範囲(半開区間や閉区間)から乱数をサンプリングします。

確率分布の生成原理

一様乱数 から、任意の確率密度関数 に従う乱数を生成するための数学的背景を解説します。

1. 逆関数法 (Inverse Transform Sampling)

累積分布関数 の逆関数 が既知である場合に有効な手法です。

数学的原理: 一様乱数 に対して と置くと、その累積分布関数 は以下のようになります。

ここで は一様分布なので です。したがって、 となり、 は目的の累積分布関数 、すなわち確率密度関数 に従うことが示されます。

具体例:指数分布 の場合、累積分布関数は です。 について解くと:

もまた の一様乱数であるため、実装上は と簡略化されます。

2. ボックス=ミュラー法 (Box-Muller transform)

正規分布(ガウス分布) のように、逆関数が初等関数で書けない場合に用いられます。

2つの一様乱数 を用いて、以下の変換を行うことで、標準正規分布 に従う独立な2つの乱数 を得られます。

これは、2次元ガウス分布を極座標 で表したとき、 が指数分布に、 が一様分布に従うという幾何学的性質を利用しています。

rand_distrによる実践的な利用

実用上は、これらの変換アルゴリズムを自分で実装する代わりに、最適化されたrand_distrクレートを使用します。

use rand::RngExt;
use rand_distr::{Distribution, Normal, Exp, Gamma};

/// 各種確率分布からのサンプリング
/// 物理現象のモデリングに応じて適切な分布を選択する
fn demonstrate_distributions() {
    println!("--- 各種確率分布からのサンプリング ---");

    let mut rng = rand::rngs::ThreadRng::default();

    // 正規分布(ガウス分布): N(平均 mu, 標準偏差 sigma)
    // 用途: 測定誤差、熱雑音、ブラウン運動の変位など
    let normal = Normal::new(0.0, 1.0).unwrap();
    let v_n = normal.sample(&mut rng);
    println!("正規分布 N(0, 1): {:.6}", v_n);
    println!("  用途例: 測定誤差、熱雑音、中心極限定理が成り立つ現象");

    // 指数分布: Exp(lambda)
    // 用途: 放射性崩壊の待ち時間、光子の自由行程、ポアソン過程のイベント間隔
    let exp = Exp::new(1.0).unwrap();
    let v_e = exp.sample(&mut rng);
    println!("\n指数分布 Exp(1.0): {:.6}", v_e);
    println!("  用途例: 放射性崩壊の待ち時間、粒子の平均自由行程");

    // ガンマ分布: Gamma(shape, scale)
    let gamma = Gamma::new(2.0, 2.0).unwrap();
    let v_g = gamma.sample(&mut rng);
    println!("\nガンマ分布 Gamma(2.0, 2.0): {:.6}", v_g);
    println!("  用途例: 複数イベントの待ち時間、ベイズ統計");
}

/// 統計的性質の確認
/// 大量のサンプルから分布の特性(平均、分散)を計算
fn demonstrate_statistics() {
    println!("--- 統計的性質の確認(N=10000サンプル)---");

    let mut rng = rand::rngs::ThreadRng::default();
    let n_samples = 10000;

    // 正規分布 N(0, 1) のサンプリング
    let normal = Normal::new(0.0, 1.0).unwrap();
    let samples_normal: Vec<f64> = (0..n_samples).map(|_| normal.sample(&mut rng)).collect();

    let (mean_n, std_n) = compute_statistics(&samples_normal);
    println!("正規分布 N(0, 1):");
    println!("  理論値: 平均 = 0.0, 標準偏差 = 1.0");
    println!("  実測値: 平均 = {:.6}, 標準偏差 = {:.6}", mean_n, std_n);

    // 指数分布 Exp(1.0) のサンプリング
    let exp = Exp::new(1.0).unwrap();
    let samples_exp: Vec<f64> = (0..n_samples).map(|_| exp.sample(&mut rng)).collect();

    let (mean_e, std_e) = compute_statistics(&samples_exp);
    println!("\n指数分布 Exp(1.0):");
    println!("  理論値: 平均 = 1.0, 標準偏差 = 1.0");
    println!("  実測値: 平均 = {:.6}, 標準偏差 = {:.6}", mean_e, std_e);

    // 一様分布 [0, 1) のサンプリング
    let samples_uniform: Vec<f64> = (0..n_samples).map(|_| rng.random::<f64>()).collect();

    let (mean_u, std_u) = compute_statistics(&samples_uniform);
    println!("\n一様分布 U(0, 1):");
    println!(
        "  理論値: 平均 = 0.5, 標準偏差 = {:.6}",
        (1.0 / 12.0_f64).sqrt()
    );
    println!("  実測値: 平均 = {:.6}, 標準偏差 = {:.6}", mean_u, std_u);
}

/// サンプルから平均と標準偏差を計算
fn compute_statistics(samples: &[f64]) -> (f64, f64) {
    let n = samples.len() as f64;
    let mean = samples.iter().sum::<f64>() / n;
    let variance = samples.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / n;
    let std_dev = variance.sqrt();
    (mean, std_dev)
}
--- 各種確率分布からのサンプリング ---
正規分布 N(0, 1): -0.750363
  用途例: 測定誤差、熱雑音、中心極限定理が成り立つ現象

指数分布 Exp(1.0): 4.913783
  用途例: 放射性崩壊の待ち時間、粒子の平均自由行程

ガンマ分布 Gamma(2.0, 2.0): 1.258798
  用途例: 複数イベントの待ち時間、ベイズ統計

--- 統計的性質の確認(N=10000サンプル)---
正規分布 N(0, 1):
  理論値: 平均 = 0.0, 標準偏差 = 1.0
  実測値: 平均 = -0.015215, 標準偏差 = 0.999117

指数分布 Exp(1.0):
  理論値: 平均 = 1.0, 標準偏差 = 1.0
  実測値: 平均 = 0.996618, 標準偏差 = 0.998381

一様分布 U(0, 1):
  理論値: 平均 = 0.5, 標準偏差 = 0.288675
  実測値: 平均 = 0.498569, 標準偏差 = 0.288859

結果の解釈:理論値と統計誤差

上記の実行結果(統計的性質の確認)を見ると、実測値(平均や標準偏差)が理論値に非常に近いものの、完全には一致していないことが分かります。これはモンテカルロ法における統計誤差によるものです。

  1. 統計的揺らぎ: サンプル数 の場合、中心極限定理によれば相対誤差は (1%)程度のオーダーで現れます。実際、結果の「平均」を見ると理論値から小数点第2位程度のズレに収まっており、サンプリングが統計的に正しく行われていることが確認できます。
  2. 再現性の確認: ChaCha8Rngを用いてシードを固定した例では、何度実行しても0.836919...という全く同じ値が得られます。一方、ThreadRngを用いた統計テストでは、内部的な不確定性により実行ごとに結果が僅かに変動します。
  3. 物理的妥当性: 例えば指数分布 では、得られた個別のサンプル値(4.913...)が正の実数であり、理論通りの範囲に収まっていることが分かります。

このように、数値計算の結果を得た際には「理論値とのズレが統計誤差の範囲内か?」を確認する習慣をつけることが、信頼性の高いシミュレーションへの第一歩となります。


次節では、これらの乱数を用いて積分の近似値を求める「モンテカルロ積分」を学びます。

Last change: , commit: 991b48c

モンテカルロ積分

Note

本節のポイント

  • 期待値と積分の関係を理解し、大数の法則に基づくモンテカルロ積分の原理を学ぶ。
  • 中心極限定理に基づき、独立サンプルで分散が有限な場合に積分の統計誤差が で収束することを導く。
  • rayonを用いた並列化の際、乱数生成器を各スレッドで独立に管理する手法(map_init)を習得する。

乱数を用いて定積分の近似値を求める手法をモンテカルロ積分と呼びます。

数学的原理

領域 における関数 の積分 を考えます。

1. 大数の法則による収束

確率変数 が領域 内で一様分布 に従うとき、関数 の期待値 は以下のようになります。

したがって、積分値 は期待値の 倍、つまり です。大数の法則(Law of Large Numbers) により、独立にサンプリングされた 個の点に対する標本平均は期待値に収束するため、以下の推定量 が得られます。

2. 誤差評価と中心極限定理

モンテカルロ積分の誤差は、中心極限定理(Central Limit Theorem) によって評価できます。推定量の分散 は、元の関数の分散 を用いて以下のように表されます。

標本平均の標準偏差(統計誤差)はこれの平方根をとって となります。 この結果は、独立サンプルで分散 が有限なら、標本数に対する収束率が になることを示しています。この収束率そのものは空間次元 を直接含みませんが、分散 や有効なサンプリング分布は次元と被積分関数に強く依存します。したがって、高次元積分でモンテカルロ法が有利になるのは、格子法のように点数が で増えることを避けられる一方で、分散を抑える工夫が必要になるためです。

実装例1:ヒット・オア・ミス法(円周率の推定)

領域内に点を打ち、特定の条件を満たす「命中」の割合から面積を求める手法です。

use rand::RngExt;

fn estimate_pi_hit_or_miss<R>(rng: &mut R, m: usize) -> f64
where
    R: RngExt + ?Sized,
{
    let mut hits = 0;

    for _ in 0..m {
        // [0, 1) の範囲で一様にサンプリング
        let x: f64 = rng.random();
        let y: f64 = rng.random();

        // 単位円の内部 (x^2 + y^2 <= 1) にあるか判定
        if x * x + y * y <= 1.0 {
            hits += 1;
        }
    }

    // 正方形の面積 (1.0) に対する円の面積 (pi/4) の比を利用
    // (hits / m) approx (pi / 4)  =>  pi approx 4 * (hits / m)
    4.0 * (hits as f64) / (m as f64)
}

fn main() {
    let m = 1_000_000;
    let mut rng = rand::rngs::ThreadRng::default();
    let pi_est = estimate_pi_hit_or_miss(&mut rng, m);

    println!("Estimated pi = {:.6}", pi_est);
}

実行ごとに出力結果が変わりますが、おおむね に近い値が得られるはずです。サンプル数を増やすと、より精度の高い推定値が得られますが、誤差の収束は であるため、精度を10倍にするにはサンプル数を100倍にする必要があります。

実装例2:並列化による高速化(rayon)

モンテカルロ法は各試行が完全に独立しているため、並列化の効果が非常に高い手法です。しかし、Rustの乱数生成器(Rng)は一般にスレッド間で共有できない(Syncではない)ため、工夫が必要です。

rayonmap_init の活用

use rand::Rng;
use rayon::iter::{IntoParallelIterator, ParallelIterator};

fn estimate_pi_parallel(m: u64) -> f64 {
    // 並列イテレータによる集計
    let hits: u64 = (0..m)
        .into_par_iter()
        .map_init(
            rand::rngs::ThreadRng::default, // 各スレッドの初期化時に一度だけ呼ばれる
            |rng, _| {
                // 各要素の処理で呼ばれる
                let x: f64 = rng.random();
                let y: f64 = rng.random();
                if x * x + y * y <= 1.0 { 1 } else { 0 }
            },
        )
        .sum();

    4.0 * (hits as f64) / (m as f64)
}

fn main() {
    let m = 100_000_000;
    let pi_est = estimate_pi_parallel(m);

    println!("Estimated pi = {:.8}", pi_est);
}
  • into_par_iter(): イテレータを並列イテレータに変換し、処理をマルチスレッドに自動分割します。
  • map_init: 各スレッド専用の「ローカル状態(ここでは乱数生成器)」を持たせるために使用します。乱数生成器は内部状態を更新するため可変(mut)である必要があり、スレッド間で共有できません。map_init を使うことでスレッドごとに独立した生成器が用意され、安全かつ高速に並列計算が行えます。

実装例1と比較して、サンプル数を100倍に増やしても、実行時間は数倍程度に抑えられるため、モンテカルロ法の大規模計算において並列化が非常に効果的であることがわかります。

まとめ

  • モンテカルロ積分は、数学的には積分を確率変数の期待値として捉え、大数の法則を利用して近似する手法である。
  • 統計誤差 の収束は遅いため、大規模計算ではrayonなどを用いた並列化が実用的である。
  • 格子法のような の点数増加を直接は受けないため、現代物理学の多体問題や統計力学で重要である。ただし、分散や有効サンプル数の評価は不可欠である。

参考リンク


次節では、この の収束を「定数倍」のレベルで劇的に改善する手法である「重点サンプリング」を学びます。

Last change: , commit: 991b48c

重点サンプリング

Note

本節のポイント

  • 分散減少法の一種である重点サンプリングの数学的原理を理解する。
  • 積分対象の関数の形状を反映した確率密度関数 を選ぶ重要性を学ぶ。
  • サンプリング分布の選択を誤ると、かえって分散が増大するリスク(テールの問題)を理解する。

単純なモンテカルロ積分では、関数の値がほとんど であるような広大な領域を律儀にサンプリングし、貴重な計算資源を浪費してしまうことがあります。これを改善するのが重点サンプリング (Importance Sampling) です。

原理:期待値の書き換え

積分 を計算したいとします。ここで、領域 で定義され、規格化( )された任意の確率密度関数 を導入すると、積分は以下のように書き換えられます。

これは、確率分布 に従って 個の点 をサンプリングし、重み付きの値 の平均をとることに相当します。

分散の最小化

この推定量の分散 は、元の積分の真値 を用いて次のように表されました。

この分散を最小化するには、第一項の積分 を最小にする を見つければよいことになります。ここで、コーシー=シュワルツの不等式 を利用します。

, と置くと:

ここで であるため、左辺は最小化したい項 そのものになります。右辺は によらない定数です。等号が成立するのは 、すなわち:

のときです。したがって、最適なサンプリング分布 は被積分関数の絶対値に比例することが示されました。

Tip

理想的なケース: もし かつ と選べたなら、 (定数)となります。このとき分散は完全に 0 になり、たった1回のサンプリングで正しい積分値が得られることになります。実用上は が未知のため不可能ですが、この「平坦化」の極限を目指すのが重点サンプリングの本質です。

実践例:指数減衰を含む積分の効率化

以下の積分を例に、一様サンプリングと重点サンプリングを比較します。

Rustによる比較実装

use rand::RngExt;
use rand_distr::{Distribution, Exp};

#[derive(Clone, Copy)]
struct Estimate {
    result: f64,
    std_error: f64,
    variance: f64,
}

fn target_integrand(x: f64) -> f64 {
    (-x).exp() / (1.0 + x * x)
}

fn uniform_estimate<R>(rng: &mut R, n_samples: usize, limit: f64) -> Estimate
where
    R: RngExt + ?Sized,
{
    // 積分区間 [0, ∞) を [0, L] で打ち切る
    // I ≈ L × E[f(X)]  where X ~ Uniform(0, L)
    let mut sum = 0.0;
    let mut sum_sq = 0.0;

    for _ in 0..n_samples {
        let x = rng.random_range(0.0..limit);
        let fx = target_integrand(x);
        sum += fx;
        sum_sq += fx * fx;
    }

    let mean = sum / (n_samples as f64);
    let mean_sq = sum_sq / (n_samples as f64);
    let variance = mean_sq - mean * mean;
    let estimator_variance = limit * limit * variance;

    Estimate {
        result: limit * mean,
        std_error: (estimator_variance / (n_samples as f64)).sqrt(),
        variance: estimator_variance,
    }
}

fn importance_weight(x: f64) -> f64 {
    // 提案分布 p(x) = e^(-x) を用いると f(x)/p(x) = 1/(1+x^2)
    1.0 / (1.0 + x * x)
}

fn importance_estimate<R>(rng: &mut R, n_samples: usize) -> Estimate
where
    R: RngExt + ?Sized,
{
    let exp_dist = Exp::new(1.0).unwrap();
    let mut sum = 0.0;
    let mut sum_sq = 0.0;

    for _ in 0..n_samples {
        let x = exp_dist.sample(rng);
        let weight = importance_weight(x);
        sum += weight;
        sum_sq += weight * weight;
    }

    let mean = sum / (n_samples as f64);
    let mean_sq = sum_sq / (n_samples as f64);
    let variance = mean_sq - mean * mean;

    Estimate {
        result: mean,
        std_error: (variance / (n_samples as f64)).sqrt(),
        variance,
    }
}

fn run_trials(
    n_samples: usize,
    n_trials: usize,
    limit: f64,
) -> (Vec<Estimate>, Vec<Estimate>) {
    let mut uniform_results = Vec::new();
    let mut importance_results = Vec::new();

    for _ in 0..n_trials {
        let mut rng = rand::rngs::ThreadRng::default();
        uniform_results.push(uniform_estimate(&mut rng, n_samples, limit));
        importance_results.push(importance_estimate(&mut rng, n_samples));
    }

    (uniform_results, importance_results)
}

fn average_estimate(results: &[Estimate]) -> Estimate {
    let n = results.len() as f64;

    Estimate {
        result: results.iter().map(|e| e.result).sum::<f64>() / n,
        std_error: results.iter().map(|e| e.std_error).sum::<f64>() / n,
        variance: results.iter().map(|e| e.variance).sum::<f64>() / n,
    }
}

fn main() {
    // 目標: 以下の積分を数値的に計算する
    //   I = ∫₀^∞ f(x) dx = ∫₀^∞ e^(-x) / (1 + x²) dx
    //
    // 厳密解 (近似値): I ≈ 0.62144962
    //
    // この積分は解析的に計算困難だが、モンテカルロ法で近似できる。
    // ただし、x → ∞ での収束が遅いため、通常の一様サンプリングは非効率。
    // 重点サンプリングを用いることで、分散を大幅に削減できる。

    let n_samples = 1_000_000;
    let n_trials = 10; // 安定性を確認するための試行回数
    let exact_value = 0.62144962;
    let limit = 10.0;

    println!("積分: ∫₀^∞ e^(-x) / (1 + x²) dx");
    println!("厳密解 (近似): {:.8}\n", exact_value);
    println!("サンプル数: {}\n", n_samples);

    let (uniform_results, importance_results) = run_trials(n_samples, n_trials, limit);
    let first_uniform = uniform_results[0];
    let first_importance = importance_results[0];

    println!("--- 試行 1 の詳細 ---");
    println!("\n[一様サンプリング (区間 [0, {}])]", limit);
    println!("  推定値:     {:.8}", first_uniform.result);
    println!("  標準誤差:   {:.8}", first_uniform.std_error);
    println!("  推定値の分散: {:.8}", first_uniform.variance);
    println!("  誤差:       {:.8}", (first_uniform.result - exact_value).abs());

    println!("\n[重点サンプリング (p(x) = e^(-x))]");
    println!("  推定値:     {:.8}", first_importance.result);
    println!("  標準誤差:   {:.8}", first_importance.std_error);
    println!("  推定値の分散: {:.8}", first_importance.variance);
    println!(
        "  誤差:       {:.8}",
        (first_importance.result - exact_value).abs()
    );

    println!(
        "\n  分散削減率: {:.2}倍 (一様 {:.4} → 重点 {:.4})",
        first_uniform.variance / first_importance.variance,
        first_uniform.variance,
        first_importance.variance
    );

    // ----------------------------------------
    // 複数試行の統計
    // ----------------------------------------
    println!("\n\n=== {} 回の試行結果 ===", n_trials);

    let avg_uniform = average_estimate(&uniform_results);
    let avg_importance = average_estimate(&importance_results);

    println!("\n一様サンプリング:");
    println!("  平均推定値:     {:.8}", avg_uniform.result);
    println!("  平均推定値分散: {:.8}", avg_uniform.variance);
    println!("  平均誤差:       {:.8}", (avg_uniform.result - exact_value).abs());

    println!("\n重点サンプリング:");
    println!("  平均推定値:     {:.8}", avg_importance.result);
    println!("  平均推定値分散: {:.8}", avg_importance.variance);
    println!(
        "  平均誤差:       {:.8}",
        (avg_importance.result - exact_value).abs()
    );

    println!("\n改善効果:");
    println!(
        "  分散削減率:  {:.2}倍",
        avg_uniform.variance / avg_importance.variance
    );
    println!(
        "  効率向上:    {:.2}倍",
        avg_uniform.variance / avg_importance.variance
    );
}

コードの解説

  • 比較の設計: 一様サンプリング(方法1)と重点サンプリング(方法2)を同じサンプル数で実行し、推定値の精度(標準誤差や分散)を直接比較しています。
  • 無限区間の扱い(一様サンプリング): 本来の積分範囲は ですが、一様分布ではサンプリングできないため、コードでは で打ち切っています。この「テールの無視」による誤差が生じる点も、一様サンプリングの限界として示されています。
  • 提案分布 の選定: 被積分関数 の主要な減衰要因である に注目し、Exp::new(1.0)(指数分布)をサンプリング分布として採用しました。
  • 分散の劇的な削減: 重点サンプリングにおける標本値は となります。これは元の よりも値の変化が非常に穏やか(平坦に近い)であるため、少ないサンプル数で真の値に収束します。実行結果の分散削減率を見ることで、その圧倒的な効率向上を確認できます。

重大な注意点:テールの問題

重点サンプリングを使う際、サンプリング分布 の裾(テール)が被積分関数 よりも早く減衰してはならないという鉄則があります。

もし、ある領域で なのに となると、その地点がたまたま選ばれた際に「重み」 が極端に巨大な値をとります。これが稀に発生することで推定値に巨大なスパイクが生じ、分散が爆発(数学的には無限大に発散)します。

Warning

が大きな値を持つ領域をカバーするだけでなく、 よりも「厚い裾(ヘビーテイル)」を持つ分布を選ぶのが安全です。

まとめ

  • 重点サンプリング は、期待値を書き換えることで「重要な領域」を集中的に探索する。
  • コーシー=シュワルツの不等式により、最適な分布は であることが導かれる。
  • 適切に を選ぶことで標本値を平坦化し、分散を劇的に抑えることができる。

参考リンク


次節では、さらに複雑で高次元な分布(解析的に からサンプリングできない場合)を扱うための手法、マルコフ連鎖モンテカルロ法を学びます。

Last change: , commit: 991b48c

マルコフ連鎖モンテカルロ法 (MCMC)

Note

本節のポイント

  • 高次元・複雑な分布をサンプリングするためのMCMCの原理と、定常分布への収束条件を理解する。
  • メトロポリス法における詳細釣合い条件と受理確率の役割を数学的に学ぶ。
  • 統計力学におけるボルツマン分布のサンプリングとしての物理的意味を習得する。
  • バーンイン期間や自己相関など、実用上の診断手法を理解する。

複雑な多変数関数や、規格化定数(物理学における分配関数)が未知の確率密度関数 に従ってサンプリングを行いたい場合、これまでの単純なサンプリング手法は通用しなくなります。これを解決するのがマルコフ連鎖モンテカルロ法(Markov Chain Monte Carlo, MCMC) です。

メトロポリス法 (Metropolis Algorithm)

MCMCの最も基本的かつ強力なアルゴリズムがメトロポリス法です。これは「現在の状態 から、次の状態 を提案し、特定の確率でそれを受け入れるか決める」という手順を繰り返すことで、目標とする分布を定常分布として持つマルコフ連鎖を構成します。

アルゴリズムの原理:詳細釣合い

マルコフ連鎖が目標とする分布 に収束するためには、状態間の遷移確率 が以下の詳細釣合い条件(Detailed Balance) を満たすことが十分条件です。

この式は、状態 から への遷移確率の流れが、逆向きの流れと完全に釣り合っていることを意味します。このとき、分布 は時間の経過(ステップ数)に対して変化しない定常分布となります。

メトロポリス法では、遷移確率を「候補の提案確率 」と「受理確率 」の積 と考えます。提案分布が対称( )である場合、受理確率を次のように設定することで詳細釣合いが満たされます:

物理学的解釈:ボルツマン分布

物理学において、状態 のエネルギーを 、逆温度を とすると、平衡状態の確率は で与えられます。このとき、受理確率はエネルギー差 を用いて次のように書けます:

  • (エネルギーが下がる)なら、必ず受理する
  • (エネルギーが上がる)なら、確率 で受理する

これは、システムが基本的には安定な(エネルギーの低い)状態を目指しつつ、熱ゆらぎによって一時的に高いエネルギー状態へも遷移できることを示しています。

Rustによる実装

1次元の正規分布 を、規格化定数を無視してサンプリングします。

use rand::RngExt;

struct ChainResult {
    samples: Vec<f64>,
    accepted: usize,
}

struct SampleStats {
    mean: f64,
    variance: f64,
    std_dev: f64,
}

fn standard_normal_unnormalized(x: f64) -> f64 {
    (-0.5 * x * x).exp()
}

fn metropolis_step<R, F>(x: f64, delta: f64, rng: &mut R, target: &F) -> (f64, bool)
where
    R: RngExt + ?Sized,
    F: Fn(f64) -> f64,
{
    // 1. 候補 x' を現在の位置 x の近傍 [-δ, δ] から提案
    let x_next = x + rng.random_range(-delta..delta);

    // 2. 受理確率 A = min(1, p(x') / p(x)) の計算
    // 比をとることで、規格化定数がキャンセルされる
    let ratio = target(x_next) / target(x);
    let acceptance_prob = ratio.min(1.0);

    // 3. 受理判定
    if rng.random::<f64>() < acceptance_prob {
        (x_next, true)
    } else {
        (x, false)
    }
}

fn metropolis_sample<R, F>(
    rng: &mut R,
    x_init: f64,
    delta: f64,
    n_steps: usize,
    target: F,
) -> ChainResult
where
    R: RngExt + ?Sized,
    F: Fn(f64) -> f64,
{
    let mut x = x_init;
    let mut samples = Vec::with_capacity(n_steps);
    let mut accepted = 0;

    for _ in 0..n_steps {
        let (x_next, was_accepted) = metropolis_step(x, delta, rng, &target);
        x = x_next;
        if was_accepted {
            accepted += 1;
        }
        samples.push(x);
    }

    ChainResult { samples, accepted }
}

fn sample_stats(samples: &[f64]) -> SampleStats {
    let n = samples.len() as f64;
    let mean = samples.iter().sum::<f64>() / n;
    let variance = samples
        .iter()
        .map(|&x| (x - mean).powi(2))
        .sum::<f64>()
        / n;

    SampleStats {
        mean,
        variance,
        std_dev: variance.sqrt(),
    }
}

fn main() {
    println!("目標分布: 標準正規分布 N(0, 1)");
    println!("提案分布: 一様ランダムウォーク");
    println!("受理確率: A = min(1, p(x')/p(x))\n");

    let mut rng = rand::rngs::ThreadRng::default();

    // パラメータ設定
    let x_init = 10.0; // 分布の中心から遠い初期値(バーンインの効果を見るため)
    let delta = 1.0; // 提案のステップ幅
    let n_steps = 100_000;
    let burn_in = n_steps / 10; // 最初の10%をバーンイン期間とする

    println!("パラメータ:");
    println!("  初期値 x₀ = {}", x_init);
    println!("  ステップ幅 δ = {}", delta);
    println!("  総ステップ数 = {}", n_steps);
    println!("  バーンイン期間 = {} ステップ\n", burn_in);

    // サンプリング実行
    let chain = metropolis_sample(
        &mut rng,
        x_init,
        delta,
        n_steps,
        standard_normal_unnormalized,
    );

    // 統計量の計算
    let acceptance_rate = (chain.accepted as f64) / (n_steps as f64);

    // バーンイン後のサンプルで統計を計算
    let valid_samples = &chain.samples[burn_in..];
    let valid_stats = sample_stats(valid_samples);

    // バーンイン前の統計(比較のため)
    let burnin_stats = sample_stats(&chain.samples[0..burn_in]);

    // 結果の表示
    println!("--- 結果 ---");
    println!("受理率: {:.2}%", acceptance_rate * 100.0);
    println!("  (理想的には 20-50% 程度が効率的)\n");

    println!("バーンイン期間の平均: {:.6}", burnin_stats.mean);
    println!("  (初期値 {} から定常分布へ収束中)\n", x_init);

    println!("バーンイン後の統計 ({} サンプル):", valid_samples.len());
    println!("  平均:       {:.6}  (理論値: 0.0)", valid_stats.mean);
    println!("  標準偏差:   {:.6}  (理論値: 1.0)", valid_stats.std_dev);
    println!("  分散:       {:.6}  (理論値: 1.0)", valid_stats.variance);

    println!("\n理論値との誤差:");
    println!("  平均の誤差: {:.6}", valid_stats.mean.abs());
    println!("  標準偏差の誤差: {:.6}", (valid_stats.std_dev - 1.0).abs());
}

コードの解説

  • 初期値とバーンイン: x_init = 10.0と、分布の中心( )から大きく離れた地点から開始しています。実行結果の「バーンイン期間の平均」が に近い値から徐々に減少していく様子を見ることで、定常分布へ到達するまでの「探索過程」を数値的に確認できます。
  • 規格化定数の不要性: 受理確率 を計算する際、分子と分母で の共通の定数倍が打ち消し合います。この性質により、物理学の分配関数のように計算が困難な定数を知らなくても、エネルギー差(分布の比)だけでサンプリングが可能になります。
  • 統計的評価: バーンイン後のサンプルのみを用いて平均と標準偏差を計算し、理論値(平均 , 標準偏差 )と比較しています。これにより、MCMCが正しく目標分布を再現できているかを検証しています。

実践的な診断と注意点

  1. バーンイン(Burn-in): ランダムウォークが目標分布の領域に到達するまでの期間です。初期値が分布の裾野(エネルギーが高い場所)にあるほど、平衡状態に達するまで時間がかかります。
  2. ステップ幅 の調整:
    • が小さすぎると、移動が遅すぎて全領域を探索するのに時間がかかる。
    • が大きすぎると、提案が分布の裾野ばかりになり、受理率が極端に下がって「停滞」する。 一般に受理率が 25%〜50% 程度になるように を調整するのが効率的です。
  3. 自己相関時間: MCMCのサンプルは独立ではないため、推定誤差は単純な よりも大きくなります。有効なサンプルサイズを見積もるには、自己相関関数の評価が必要です。

まとめ

  • MCMC は、状態遷移の繰り返しを通じて高次元・複雑な分布を探索する。
  • メトロポリス法 は、受理確率を適切に設定することで詳細釣合いを実現し、目標分布への収束を保証する。
  • 分布の絶対値が分からなくても、比さえ計算できればサンプリング可能である点が、物理学における強力な武器となる。

参考リンク


本章はこれで終わりです。古典力学シミュレーションからは、これらの数値手法で具体的な物理シミュレーションを解いてみましょう。

Last change: , commit: 991b48c

古典力学シミュレーション

Important

この章を読む前に

この章を読むには、以下の章を先に読んでおく必要があります。

物理シミュレーションにおいて、最も基本的かつ直感的なのが古典力学 (Classical Mechanics) のシミュレーションです。 粒子の動きを追跡することは、惑星の軌道計算から気体分子の運動、さらには銀河の形成に至るまで、あらゆるスケールの現象理解の基礎となります。

数学的には、これはニュートンの運動方程式を数値的に解くことに他なりません。

本章では、常微分方程式で学んだ解法を物理の問題に応用し、さらに物理系特有の性質(エネルギー保存則など)に適した高度なアルゴリズムへと発展させていきます。

本章の構成

  1. 質点系の運動 運動方程式を数値計算に適した1階の連立微分方程式に変換する方法と、基本的な空気抵抗のある落体のシミュレーションを行います。

  2. シンプレクティック積分法 標準的なルンゲ=クッタ法が抱える「エネルギー保存の破れ」という問題を指摘し、長時間の安定計算を可能にするシンプレクティック積分(オイラー・クローマー法、速度ベレ法)を導入します。

  3. 惑星運動(ケプラー問題) 万有引力を受けて運動する惑星の軌道を計算します。ここで ndarray クレートを用いたベクトル演算の実装方法を学び、シンプレクティック積分の威力を確認します。

  4. 分子動力学入門 多数の粒子が相互作用する多体系のシミュレーション(分子動力学法)を構築します。周期境界条件やレナード・ジョーンズ・ポテンシャルの実装を通じて、実践的な物理シミュレーションの基礎を固めます。

作業テーマ

作業ディレクトリは ~/rust-computational-physics-work/ からの相対パスです。 ユニットテストで確認する具体的なケースは、解析解、境界条件、許容誤差の観点からAI coding agentと相談して決めます。

テーマ作業ディレクトリ構造化するコードユニットテスト演習拡張演習
質点系の運動classical-mechanics/particle-motionforce, state_derivative, rk4_step空気抵抗なしの放物運動を解析解と比較する軌道をCSVに出力する
シンプレクティック積分classical-mechanics/symplecticeuler_cromer_step, velocity_verlet_step, energy調和振動子でエネルギーの変化を確認するEuler法と長時間安定性を比較する
Kepler問題classical-mechanics/kepleracceleration, energy, angular_momentum, step_verlet円軌道のエネルギーと角運動量を確認する軌道と保存量をCSVに出力する
分子動力学classical-mechanics/molecular-dynamicsminimum_image, pair_force, compute_forces, kinetic_energy周期境界、LJ力のゼロ点、作用反作用を確認するSoA化し、cargo benchで力計算を比較する

検証と実装の観点

古典力学のシミュレーションでは、軌道の見た目だけでなく、保存量と単位系を確認します。 特に長時間積分では、短時間の局所誤差よりもエネルギーや角運動量のdriftが重要になる場合があります。

  • force、state、time step、保存量の計算を分けて実装する。
  • 解析解、保存量、対称性、作用反作用を小さいテストで確認する。
  • 初期条件、単位系、time step、積分手法をmetadataとして残す。
  • agentに実装を任せた場合も、力の符号、境界条件、保存量の定義をdiffで確認する。

質点系の運動

物理学、特に古典力学におけるシミュレーションの基礎となるのは、ニュートンの運動方程式 (Newton’s Equation of Motion) です。 本節では、運動方程式を数値計算可能な形、すなわち常微分方程式 (ODE) の形に定式化する方法を確認します。

運動方程式の定式化

質量 の質点にかかる力を 、位置を とすると、運動方程式は以下のように記述されます。

ここで、 は速度です。 常微分方程式で学んだ標準的な数値解法(オイラー法やルンゲ=クッタ法)を適用するためには、この2階の微分方程式を1階の連立微分方程式に書き直す必要があります。

状態ベクトル を位置と速度を並べたものとして定義します。

このとき、状態ベレトルの時間微分 は次のように得られます。

これを一般化した形式 とみなせば、常微分方程式のアルゴリズムがそのまま利用可能になります。

自由落下と空気抵抗

例として、空気抵抗がある場合の自由落下を考えます。 重力加速度を 、空気抵抗が速度に比例すると仮定します(係数 )。

運動方程式を1階の連立微分方程式に分解すると:

Rustによる実装例

ndarray入門で学んだndarrayを使用して、このダイナミクスを実装してみましょう。状態ベクトル の各要素を と対応させます。

use ndarray::{Array1, arr1};

struct FallingBody {
    m: f64, // 質量
    k: f64, // 空気抵抗係数
    g: f64, // 重力加速度
}

impl FallingBody {
    fn new(m: f64, k: f64) -> Self {
        Self { m, k, g: 9.8 }
    }

    /// ODEソルバーが期待する形式: f(t, y) -> dy/dt
    /// 状態ベクトル y = [x, y, vx, vy]
    fn dynamics(&self, _t: f64, y: &Array1<f64>) -> Array1<f64> {
        // 現在の速度を取り出す
        let vx = y[2];
        let vy = y[3];

        // 加速度の計算: a = F/m = (重力 + 空気抵抗) / m
        // ax = - (k/m) * vx
        let ax = -(self.k / self.m) * vx;
        // ay = -g - (k/m) * vy
        let ay = -self.g - (self.k / self.m) * vy;

        // dy/dt = [dx/dt, dy/dt, dvx/dt, dvy/dt] = [vx, vy, ax, ay] を返す
        arr1(&[vx, vy, ax, ay])
    }
}

fn main() {
    let model = FallingBody::new(1.0, 0.1);

    // 初期状態: 原点から初速 (10.0, 15.0) で投げ出された状態
    let y = arr1(&[0.0, 0.0, 10.0, 15.0]);

    // 時刻 t=0 における変化率 (微分値) を計算
    let dy_dt = model.dynamics(0.0, &y0);

    println!("現在の状態: {:?}", y0);
    println!("微分値 (dy/dt): {:?}", dy_dt);
    // これを Runge-Kutta ソルバー等に渡すことで
    // 時間発展をシミュレーションできる
}

自由落下のシミュレーション

このようにndarray::Array1を用いることで、多次元の状態ベクトルも簡潔に扱うことができます。

なぜ1階に落とすのか?

数値計算のアルゴリズム(例えば4次ルンゲ=クッタ法)の多くは、数学的に「1階の微分方程式」を解くように設計されています。 物理的な「加速度(2階微分)」を「速度の1階微分」と「位置の1階微分」に分離することで、汎用的なソルバーを活用できるというメリットがあります。

しかし、次節で学ぶように、物理系が持つエネルギー保存則などの性質をより厳密に維持したい場合には、位置と速度をあえて非対称に(あるいは特定の順序で)更新する専用の手法が必要になります。

解析力学との関係

古典力学のシミュレーションには、ニュートン力学だけでなく、解析力学(ラグランジュ形式・ハミルトン形式) の視点も重要です。 特にハミルトン形式は、ハミルトニアン (全エネルギーに対応)を用いて、運動方程式を対称的な1階の微分方程式として記述します。

ここで は一般化座標、 は一般化運動量です。 次節では、このハミルトン力学の構造を数値的に保存するシンプレクティック積分について学びます。

Last change: , commit: ec78068

シンプレクティック積分

常微分方程式で学んだルンゲ=クッタ法(RK4)は非常に高精度ですが、天体の軌道計算や分子動力学シミュレーションのように「エネルギー保存則が成り立つ系(ハミルトン系)」を長時間計算する場合、エネルギーが徐々に保存されなくなるという問題があります。

本節では、ハミルトン系の幾何学的構造(シンプレクティック構造)を保存するように設計されたシンプレクティック積分法について学びます。

なぜ標準的な手法ではダメなのか?

標準的な手法(オイラー法やルンゲ=クッタ法)は、解を の多項式で近似することに主眼を置いています。しかし、これらは位相空間(位置 と運動量 の空間)における面積保存(リウヴィルの定理)を必ずしも満たしません。

結果として、例えば調和振動子の計算において:

  • 前進オイラー法:エネルギーが指数関数的に増大し、軌道が外側に螺旋を描く。
  • ルンゲ=クッタ法 (RK4):エネルギー誤差は非常に小さいが、長時間計算では単調に減衰(あるいは増大)し続け、元に戻らない。

シンプレクティック積分の概念

オイラー・クローマー法

最も単純なシンプレクティック積分は、オイラー・クローマー法 (Euler-Cromer method) です。

オイラー・クローマー法(1次精度):

(先に速度を更新)

更新後の速度を使って位置を更新)

たったこれだけの変更ですが、この手法は位相空間上の面積を保存し、エネルギーが平均的に一定値を保つようになります。

速度ベレ法 (Velocity Verlet)

分子動力学などで最も広く使われるのが速度ベレ法 (Velocity Verlet method) です。これは2次精度であり、時間反転対称性を持ちます。

位置を更新:

新しい位置での加速度 を計算。

速度を更新:

2次精度の導出

速度ベレ法の精度は、位置 のテイラー展開を考えることで導かれます。

速度ベレ法の位置の更新式は、このテイラー展開の の項までを厳密に含んでいます。したがって、1ステップあたりの局所誤差は となり、累積される全誤差は 2次精度となります。

また、速度の更新式は における加速度の平均を用いる「台形公式」の形をしており、これも2次精度を支えています。

Rustによる実装とエネルギー監視

調和振動子( )を例に、エネルギーの保存性を確認できるコードを実装します。

use ndarray::{Array1, arr1};

struct Particle {
    pos: Array1<f64>,
    vel: Array1<f64>,
}

impl Particle {
    fn new(x: f64, v: f64) -> Self {
        Self { pos: arr1(&[x]), vel: arr1(&[v]) }
    }

    // 全エネルギー E = 1/2 v^2 + 1/2 x^2
    fn energy(&self) -> f64 {
        0.5 * (self.vel[0].powi(2) + self.pos[0].powi(2))
    }
}

fn get_acceleration(pos: &Array1<f64>) -> Array1<f64> {
    -pos // 復元力 F = -x
}

fn velocity_verlet_step(p: &mut Particle, dt: f64) {
    let a_curr = get_acceleration(&p.pos);

    // 1. 位置の更新: x(t+dt) = x(t) + v(t)dt + 0.5*a(t)dt^2
    p.pos += &(&p.vel * dt + 0.5 * &a_curr * dt * dt);

    // 2. 新しい位置での加速度 a(t+dt)
    let a_next = get_acceleration(&p.pos);

    // 3. 速度の更新: v(t+dt) = v(t) + 0.5*(a(t) + a(t+dt))dt
    p.vel += &(0.5 * (&a_curr + &a_next) * dt);
}

fn main() {
    let mut p = Particle::new(1.0, 0.0);
    let dt = 0.1;

    println!("Time, Position, Energy");
    for i in 0..101 {
        let t = i as f64 * dt;
        if i % 10 == 0 {
            println!("{:.1}, {:.4}, {:.6}", t, p.pos[0], p.energy());
        }
        velocity_verlet_step(&mut p, dt);
    }
}

速度ベレ法のエネルギー保存

高次のシンプレクティック積分

4次精度が必要な場合、RK4の代わりにフォレスト=ルース (Forest-Ruth) の4次シンプレクティック積分 などを用いることができます。これは、1ステップを複数のサブステップに分割し、特定の係数 を用いて更新を繰り返す手法です。

のように係数を定めることで、シンプレクティック性を保ったまま4次精度を達成できます。

なぜシンプレクティック積分はエネルギーを保存するのか?

厳密には、シンプレクティック積分が保存するのは元のハミルトニアン そのものではなく、それに非常に近い 「影のハミルトニアン (Shadow Hamiltonian) です。なぜこのような性質が生まれるのか、その理由を幾何学的に説明します。

1. 位相空間の面積保存

ハミルトン力学において、時間発展は位相空間 上の面積(正確にはシンプレクティック形式)を保存する写像であることが知られています。

オイラー・クローマー法の1ステップ のヤコビ行列 を計算してみると:

この写像のヤコビ行列の行列式は となります。これは、この数値スキームが位相空間の体積を厳密に保存していることを意味します。一方、標準的なオイラー法では となり、ステップごとに体積が膨張(または収縮)してしまいます。

2. 後退誤差解析と影のハミルトニアン

「面積を保存する離散的な写像」は、ある別のハミルトン系の厳密な解として解釈できるという定理があります。

つまり、私たちがシンプレクティック積分で解いているのは、元の系 ではなく、

という、時間刻み に依存する「少しだけずれた系」の厳密な解なのです。

  • この は数値計算中、マシン精度(浮動小数点の丸め誤差)を除いて厳密に保存されます。
  • 真のエネルギー は、この保存量 の周りを の幅で振動し続けるため、長時間経過しても誤差が累積(ドリフト)することはありません。

これが、シンプレクティック積分が「エネルギーを保存する」と言われる理由の幾何学的な正体です。

参考リンク

Last change: , commit: 991b48c

ケプラー問題と軌道安定性

シンプレクティック積分の恩恵を最も実感できる例の一つが、重力下での惑星の運動(ケプラー問題)です。 本節では、太陽の周りを回る地球の軌道をシミュレーションし、物理量の保存(エネルギー・角運動量)を確認します。

運動方程式

原点に質量 の恒星があり、位置 にある質量 の惑星が万有引力を受けて運動しているとします。 運動方程式は以下の通りです。

この系はハミルトン系であり、全エネルギー 、角運動量 、およびルンゲ=レンツベクトルが保存されます。

ndarrayによる実装とエネルギー監視

ndarray入門で導入したndarrayクレートを使用して、速度ベレ法を実装します。

use ndarray::{Array1, arr1};

// 天文単位系 (AU, Year, Solar Mass) では G*M = 4 * pi^2
const GM: f64 = 4.0 * std::f64::consts::PI * std::f64::consts::PI;

struct Planet {
    pos: Array1<f64>,
    vel: Array1<f64>,
}

impl Planet {
    fn new(x: f64, y: f64, vx: f64, vy: f64) -> Self {
        Self {
            pos: arr1(&[x, y]),
            vel: arr1(&[vx, vy]),
        }
    }

    // 全エネルギー E = K + U
    fn total_energy(&self) -> f64 {
        let v_sq = self.vel.dot(&self.vel);
        let r = self.pos.dot(&self.pos).sqrt();
        0.5 * v_sq - GM / r
    }

    // 角運動量 L = r x v (2次元ではスカラー)
    fn angular_momentum(&self) -> f64 {
        self.pos[0] * self.vel[1] - self.pos[1] * self.vel[0]
    }
}

fn compute_acceleration(pos: &Array1<f64>) -> Array1<f64> {
    let r_sq = pos.dot(pos);
    let r_inv_cb = 1.0 / (r_sq * r_sq.sqrt());
    -GM * r_inv_cb * pos
}

fn velocity_verlet_step(planet: &mut Planet, dt: f64) {
    let a_curr = compute_acceleration(&planet.pos);

    // 1. 位置更新
    planet.pos += &(&planet.vel * dt + 0.5 * &a_curr * dt * dt);

    // 2. 新しい加速度
    let a_next = compute_acceleration(&planet.pos);

    // 3. 速度更新
    planet.vel += &(0.5 * (&a_curr + &a_next) * dt);
}

fn main() {
    // 地球の初期条件 (r=1.0 AU, v=2*pi AU/yr)
    let mut earth = Planet::new(1.0, 0.0, 0.0, 2.0 * std::f64::consts::PI);
    let dt = 0.001; // 約8時間の刻み幅

    println!("Time, X, Y, Energy, L");
    for i in 0..2000 {
        if i % 10 == 0 {
            println!(
                "{:.3}, {:.4}, {:.4}, {:.6}, {:.6}",
                i as f64 * dt,
                earth.pos[0],
                earth.pos[1],
                earth.total_energy(),
                earth.angular_momentum()
            );
        }
        velocity_verlet_step(&mut earth, dt);
    }
}

軌道の安定性と誤差

このコードを実行すると、エネルギーや角運動量が時間とともにわずかに変動するものの、数千ステップ経過してもその平均値が一定に保たれることがわかります。

一方、オイラー法で学んだ方法を用いた場合、エネルギーは指数関数的に増大し、惑星は螺旋を描いて太陽から遠ざかってしまいます。 また、RK4(4次ルンゲ=クッタ法)を用いれば短期的には非常に高精度ですが、シンプレクティックではないため、非常に長い時間のシミュレーション(数万年規模)では誤差が一方的に蓄積し、軌道が縮小したり拡大したりする「軌道ドリフト」が発生します。

保存量の物理的意味

  1. エネルギー保存: 軌道の大きさが保たれる。
  2. 角運動量保存: ケプラーの第2法則(面積速度一定)に対応。
  3. ルンゲ=レンツベクトル保存: 楕円軌道の向き(近日点の位置)が固定される。

シンプレクティック積分は、これらの物理的性質を数値的に「壊さない」ように設計されているため、天体力学のような長期にわたる安定性が求められる系において、標準的な高精度解法よりも優れた結果をもたらします。

Last change: , commit: ec78068

分子動力学シミュレーション

これまでの知識を総動員して、分子動力学法 (Molecular Dynamics, MD) のシミュレーションを構築しましょう。 MDは、原子や分子の動きをニュートンの運動方程式に従って計算し、物質の巨視的な性質(温度、圧力、相転移など)を微視的な視点から解析する強力な手法です。

レナード・ジョーンズ・ポテンシャル

希ガス(アルゴンなど)の原子間相互作用を記述する最も代表的なモデルが、レナード・ジョーンズ (Lennard-Jones, LJ) ポテンシャルです。

レナード・ジョーンズ・ポテンシャル

このポテンシャルは、2つの物理的効果を組み合わせています:

  1. 遠距離の引力 ( 項): 分子間の「ファンデルワールス力(分散力)」を表します。
  2. 近距離の反発 ( 項): 電子雲の重なりによる「パウリの排他律」に由来する強い反発力を表します。

ポテンシャルが最小(最も安定)となる距離は です。

初期条件:格子配置と温度

MDシミュレーションを始めるには、粒子の「初期位置」と「初期速度」を決める必要があります。

  1. 初期位置: 粒子が重なりすぎないよう、正方格子状などに並べるのが一般的です。
  2. 初期速度: 粒子を静止状態で始めると、力が釣り合って動かないか、非常に退屈なシミュレーションになります。物理的には、物質の「温度」は粒子の「熱運動(運動エネルギー)」に対応します。そのため、各粒子にランダムな初速を与えることで、系に特定の温度を導入します。

実践的な実装

この例では、位置・速度・加速度をすべて形状 (n, 2) の配列で持ちます。0番目の軸が粒子番号、1番目の軸が座標成分 (x, y) です。この順序にすると pos.row(i) が「粒子 i の位置ベクトル」になり、粒子ごとの距離計算を読みやすく書けます。

性能を強く意識する場合は、座標成分ごとの連続性を優先して (2, n) にする、あるいは x, y, vx, vy を別々の配列で持つ SoA (Structure of Arrays) 形式にする選択もあります。ここでは、まずアルゴリズムを見通しやすくするために (n, 2) を使います。

use ndarray::{Array1, Array2, Axis};
use ndarray_rand::RandomExt;
use ndarray_rand::rand_distr::Uniform;

struct MDSystem {
    n: usize,
    l: f64,
    // Shape: (n, 2). Axis 0 is the particle index; axis 1 is (x, y).
    // This makes `pos.row(i)` the position vector of particle i.
    pos: Array2<f64>,
    vel: Array2<f64>,
    acc: Array2<f64>,
}

impl MDSystem {
    fn new(n: usize, l: f64, target_temp: f64) -> Self {
        let mut pos = Array2::zeros((n, 2));
        let n_side = (n as f64).sqrt() as usize;
        let spacing = l / n_side as f64;

        for i in 0..n {
            pos[[i, 0]] = (i % n_side) as f64 * spacing + spacing * 0.5;
            pos[[i, 1]] = (i / n_side) as f64 * spacing + spacing * 0.5;
        }

        // 1. ランダムな初速を与える(-0.5 ~ 0.5 の一様分布)
        let mut vel = Array2::<f64>::random((n, 2), Uniform::new(-0.5, 0.5));

        // 2. 重心速度をゼロにする(系全体のドリフトを防ぐ)
        let mean_vel = vel.mean_axis(Axis(0)).unwrap();
        vel -= &mean_vel;

        // 3. 温度(運動エネルギー)の調整
        // 2次元の場合、自由度あたりのエネルギーから温度をスケーリング
        let current_temp = 0.5 * vel.mapv(|v: f64| v.powi(2)).sum() / n as f64;
        let scale = (target_temp / current_temp).sqrt();
        vel *= scale;

        Self { n, l, pos, vel, acc: Array2::zeros((n, 2)) }
    }

    fn get_dr(&self, i: usize, j: usize) -> Array1<f64> {
        let mut dr = &self.pos.row(i) - &self.pos.row(j);
        for k in 0..2 {
            if dr[k] > self.l * 0.5 { dr[k] -= self.l; }
            else if dr[k] < -self.l * 0.5 { dr[k] += self.l; }
        }
        dr
    }

    fn compute_forces(&mut self) -> f64 {
        self.acc.fill(0.0);
        let mut pot = 0.0;
        for i in 0..self.n {
            for j in (i + 1)..self.n {
                let dr = self.get_dr(i, j);
                let r2 = dr.dot(&dr);
                if r2 < 9.0 { // カットオフ 3.0
                    let r2_inv = 1.0 / r2;
                    let r6_inv = r2_inv * r2_inv * r2_inv;
                    pot += 4.0 * (r6_inv * r6_inv - r6_inv);
                    let f_scalar = 24.0 * r2_inv * (2.0 * r6_inv * r6_inv - r6_inv);
                    for k in 0..2 {
                        self.acc[[i, k]] += f_scalar * dr[k];
                        self.acc[[j, k]] -= f_scalar * dr[k];
                    }
                }
            }
        }
        pot
    }

    fn step(&mut self, dt: f64) -> f64 {
        self.pos += &(&self.vel * dt + 0.5 * &self.acc * dt * dt);
        self.pos.mapv_inplace(|x| x.rem_euclid(self.l));
        let old_acc = self.acc.clone();
        let pot = self.compute_forces();
        self.vel += &(0.5 * (&old_acc + &self.acc) * dt);
        pot
    }
}

fn main() {
    // 16粒子、サイズ10.0の箱、温度0.5で初期化
    let mut system = MDSystem::new(16, 10.0, 0.5);
    let dt = 0.01;
    system.compute_forces();

    println!("Step, Potential, Kinetic, Total");
    for i in 0..101 {
        let pot = system.step(dt);
        let kin = 0.5 * system.vel.mapv(|v| v.powi(2)).sum();
        if i % 10 == 0 {
            println!("{:>4}, {:>10.4}, {:>10.4}, {:>10.4}", i, pot, kin, pot + kin);
        }
    }
}

MDシミュレーションの結果

演習

この節のコードは、まず正しさを小さいテストで固定し、その後でメモリ配置や力計算を最適化する流れで発展させます。最適化の前後で同じ unit test が通るようにしておくと、性能改善によるバグを見つけやすくなります。

1. Unit testで物理的な性質を確認する

src/lib.rs に計算部分を分け、main.rs は初期化、時間発展、CSV出力だけを担当する形にします。少なくとも以下の関数をテスト可能にしてください。

  • minimum_image(dx, box_length): 周期境界条件のもとで、最短の変位を返す。
  • pair_force(dr): 2粒子間の Lennard-Jones 力を返す。
  • compute_forces(system): 全粒子の力とポテンシャルエネルギーを計算する。
  • kinetic_energy(vel): 速度配列から運動エネルギーを計算する。

確認するテストの例:

  • minimum_image(6.0, 10.0)-4.0 になること。
  • 2粒子系で、粒子 i が受ける力と粒子 j が受ける力が符号反転していること(作用反作用)。
  • r = 2^(1/6) sigma 付近で Lennard-Jones 力がほぼ 0 になること。
  • 速度を既知の値にしたとき、kinetic_energy が手計算と一致すること。

浮動小数点の比較には、絶対誤差 1e-10 や相対誤差を使います。乱数を含む初期化は unit test から切り離し、固定した位置・速度でテストします。

2. メモリ配置を変えて力計算を比較する

現在の実装は (n, 2) の配列を使っています。これは pos.row(i) で粒子 i の位置ベクトルを取り出せるため、アルゴリズムの説明には向いています。一方、粒子数が増えると、メモリアクセスの局所性や不要な一時配列の生成が性能に効いてきます。

最適化演習として、次の2つの実装を比較してください。

  • AoSに近い実装: 現在の (n, 2) 配列を使う。
  • SoA実装: x, y, vx, vy, ax, ay を別々の Vec<f64> または Array1<f64> として持つ。

両方の実装で、同じ初期条件に対して compute_forces のポテンシャルエネルギーと各粒子の力が同じになることを unit test で確認します。最適化の評価は、正しさのテストを通した後で行います。

3. cargo benchで測定する

ベンチマークでは、時間発展全体ではなく、まず最も重い compute_forces だけを測ります。cargo bench と Criterion の設定は、パフォーマンス測定とプロファイリングを参照してください。

測定するときは、以下を固定します。

  • 粒子数 n(例:256, 1024, 4096
  • box size と cutoff
  • 初期位置の生成方法
  • 比較する実装((n, 2) 版、SoA版)

ベンチマーク結果だけで判断せず、cargo test で物理的な不変条件が保たれていることを確認してから、実装を採用してください。

パフォーマンスと並列化

粒子数 が増えると、力計算の二重ループがボトルネックとなります(計算量 )。 これを解決するためには、Rayonによるデータ並列化で学ぶRayonを用いた並列計算や、計算量を に抑える近接リスト法などの工夫が必要になります。

まとめ

本章では、単純な落体から多数の粒子系まで、古典力学のシミュレーション手法を学びました。 特にシンプレクティック積分の重要性と、ndarrayを用いたベクトル演算の実装方法は、物理シミュレーション全般に応用できる極めて重要なテクニックです。

参考リンク

次章では、これらをさらに発展させ、連続体の力学である「流体力学」について学びます。

Last change: , commit: 656a13f

流体力学シミュレーション

Important

この章を読む前に

この章を読むには、以下の章を先に読んでおく必要があります。

私たちの身の回りにある空気や水の流れ、さらには天気予報や航空機の設計に至るまで、流体の動きを理解し予測することは工学的・科学的に極めて重要です。 数値流体力学 (Computational Fluid Dynamics, CFD) は、コンピュータを用いて流体現象をシミュレーションする分野であり、現代の設計開発には欠かせないツールとなっています。

本章では、流体の支配方程式であるナビエ=ストークス方程式を理解し、それを数値的に解くための代表的な2つのアプローチを学びます。

本章の構成

  1. ナビエ=ストークス方程式の基礎 流体の運動を記述する支配方程式(連続の式、運動量保存則)を導出し、非圧縮性流体の特徴と数値計算上の難しさ(圧力の扱い)について解説します。

  2. 差分法による流体シミュレーション 偏微分方程式を直接離散化する古典的な手法(MAC法)を用いて、キャビティ流れをシミュレーションします。圧力のポアソン方程式を解くプロセスをRustで実装します。

  3. 格子ボルツマン法 (LBM) 流体を仮想粒子の集団として捉える、比較的新しいシミュレーション手法です。アルゴリズムが単純で並列化に適しているため、GPUコンピューティングなどで威力を発揮します。

作業テーマ

作業ディレクトリは ~/rust-computational-physics-work/ からの相対パスです。 ユニットテストで確認する具体的なケースは、解析解、境界条件、許容誤差の観点からAI coding agentと相談して決めます。

テーマ作業ディレクトリ構造化するコードユニットテスト演習拡張演習
Navier-Stokes方程式fluid-dynamics/navier-stokesdivergence, laplacian, pressure_gradient小さい速度場で発散と差分を確認する無次元数やgrid spacingをmetadataに保存する
差分法によるCFDfluid-dynamics/finite-difference-cfdadvect, diffuse, project, apply_boundary小さい格子で境界条件とprojectionを確認するcavity flowの速度場をCSVに出力する
格子Boltzmann法fluid-dynamics/lattice-boltzmannequilibrium, collide, stream, macroscopic一様分布で密度と速度が保たれることを確認する障害物まわりの流れを保存する

検証と実装の観点

流体シミュレーションでは、格子、境界条件、安定条件、保存量を分けて確認します。 圧力、速度、分布関数を同じ関数に詰め込むと、誤差や不安定化の原因を追いにくくなります。

  • 差分演算、境界条件、time step、出力処理を分けて実装する。
  • divergence、mass、residualなど、問題に応じた診断量を確認する。
  • grid shape、spacing、Reynolds number、time step、境界条件をmetadataとして残す。
  • field dataはCSVで足りない場合、NumPy形式やHDF5などの配列形式も検討する。

ナビエ=ストークス方程式の基礎

流体の運動は、物理学の中でも特に複雑かつ美しい現象の一つです。 その支配方程式であるナビエ=ストークス方程式 (Navier-Stokes Equations) は、ニュートンの運動方程式を流体(連続体)に適用することで導かれます。

支配方程式

非圧縮性(密度 が一定)のニュートン流体(粘性が一定)を考えると、支配方程式は以下の2つになります。

1. 連続の式 (質量保存則)

流体が湧き出したり消滅したりしないことを表します。非圧縮性流体では、速度場の発散がゼロになることを意味します。

ここで は流速ベクトルです。

2. ナビエ=ストークス方程式 (運動量保存則)

流体微小要素に対する運動方程式 です。

各項の意味は以下の通りです。

  • 左辺第1項 : 局所的な時間変化(非定常項)。
  • 左辺第2項 : 移流項(対流項)。流体が移動することによって運ばれる運動量の変化を表します。この非線形項が流体現象の複雑さ(乱流など)の主因です。
  • 右辺第1項 : 圧力勾配項。圧力差によって生じる力です。
  • 右辺第2項 : 粘性項(拡散項)。流体の粘り気による摩擦力を表します。 は動粘性係数です。
  • 右辺第3項 : 外力項(重力など)。

数値計算における困難

この方程式を数値的に解く上での最大の課題は、圧力 の扱いです。 状態方程式( など)が存在する圧縮性流体とは異なり、非圧縮性流体では圧力を決定する直接的な方程式がありません。 その代わり、圧力は「連続の式 を満たすように定まる」という役割を担っています。

次節で紹介する MAC法 などの数値解法では、ポアソン方程式を解くことで、この条件を満たす圧力を計算します。

この「速度と圧力の連成」をどのように解くかが、CFD(数値流体力学)の核心部分となります。

Last change: , commit: 9d9d1d8

差分法による流体シミュレーション

ここでは、非圧縮性流体の数値シミュレーションにおける古典的かつ基本的な手法である MAC法 (Marker-and-Cell method) の考え方に基づき、差分法を用いてナビエ=ストークス方程式を解く方法を学びます。

例題として、正方形の容器の上壁が一定速度で動く「キャビティ流れ(Cavity Flow)」を扱います。

キャビティ流れの概要

スタガード格子 (Staggered Grid)

流体計算では、圧力と速度を同じ位置で定義すると、圧力が市松模様に振動してしまう(チェッカーボード不安定性)という問題が知られています。 これを防ぐために、圧力 をセルの中心に、流速 をセルの界面(中心から半歩ずれた位置)に配置するスタガード格子が用いられます。

スタガード格子の配置

  • : 格子点 の中心
  • : 格子点 の間の右側の壁
  • : 格子点 の間の上側の壁

アルゴリズムの概要(簡易MAC法)

時間ステップごとに以下の処理を行います。

  1. 仮の速度の計算: 現在の速度場 を用いて、粘性項と移流項のみを考慮し、仮の速度 を計算します(オイラー法などの時間積分)。

  1. 圧力のポアソン方程式を解く: 連続の式 を満たすように圧力 を決定します。

これをSOR法(逐次過緩和法)などで反復計算して解きます。

  1. 速度の修正: 求めた圧力勾配を使って、仮の速度を修正し、次のステップの速度 とします。

Rustによる実装例

簡略化のため、ここではスタガード格子ではなく、通常の格子(Collocated Grid)を用いた実装例を示します(※本格的な計算にはスタガード格子が推奨されます)。

use ndarray::{Array2, s, Zip};

const N: usize = 50;  // 格子数
const DT: f64 = 0.01; // 時間刻み
const NU: f64 = 0.1;  // 動粘性係数
const RHO: f64 = 1.0; // 密度

struct FluidSolver {
    u: Array2<f64>,
    v: Array2<f64>,
    p: Array2<f64>,
}

impl FluidSolver {
    fn new() -> Self {
        Self {
            u: Array2::zeros((N, N)),
            v: Array2::zeros((N, N)),
            p: Array2::zeros((N, N)),
        }
    }
    
    // 境界条件の設定(上壁が右へ移動)
    fn apply_boundary_conditions(&mut self) {
        // 壁面での滑りなし条件
        self.u.slice_mut(s![0, ..]).fill(0.0); // 下
        self.u.slice_mut(s![N-1, ..]).fill(1.0); // 上 (駆動)
        self.u.slice_mut(s![.., 0]).fill(0.0); // 左
        self.u.slice_mut(s![.., N-1]).fill(0.0); // 右

        self.v.slice_mut(s![0, ..]).fill(0.0);
        self.v.slice_mut(s![N-1, ..]).fill(0.0);
        self.v.slice_mut(s![.., 0]).fill(0.0);
        self.v.slice_mut(s![.., N-1]).fill(0.0);
    }
    
    // 圧力ポアソン方程式を解く (ヤコビ法)
    fn solve_pressure(&mut self) {
        let mut p_next = self.p.clone();
        
        // ソース項 b の計算 (div u*)
        // ... (省略: 中心差分による計算) ...
        
        // 反復計算
        for _ in 0..50 {
            // p(i,j) = 0.25 * (p(i+1,j) + p(i-1,j) + p(i,j+1) + p(i,j-1) - b)
            // ndarrayのZipやスライス操作を使うと効率的
            // ...
        }
    }
    
    fn step(&mut self) {
        // 1. 仮速度の計算(移流項 + 粘性項)
        // 2. 圧力ポアソン方程式の求解
        self.solve_pressure();
        // 3. 速度修正(圧力勾配項)
        // 4. 境界条件適用
        self.apply_boundary_conditions();
    }
}

(※完全なコードは長くなるため、主要な構造のみを示しています。実際の計算には、移流項の風上差分化などの安定化手法が必要になります。)

このシミュレーションを実行すると、容器の中心に大きな渦(一次渦)が発生し、四隅に小さな渦(二次渦)ができる様子が観察できます。 流体のシミュレーションは計算量が非常に多いため、Rustの高速性が大いに活きる分野です。

参考リンク

Last change: , commit: 991b48c

格子ボルツマン法 (LBM)

格子ボルツマン法 (Lattice Boltzmann Method, LBM) は、流体を連続体としてではなく、離散的な速度を持つ仮想粒子の集合体としてモデル化する手法です。 ナビエ=ストークス方程式を直接解くのではなく、ボルツマン方程式を簡略化したモデルを解くことで、巨視的には流体として振る舞う結果を得ます。

基本概念

分布関数

分布関数 は、位置 、時刻 において、離散速度 で移動している粒子の密度(確率分布)を表します。

D2Q9モデル

2次元シミュレーションで最も一般的なモデルです。 各格子点において、粒子は以下の9方向のいずれかの速度を持ちます。

D2Q9モデルの速度ベクトル

  • 中心(静止): 1つ
  • 上下左右(近傍): 4つ
  • 斜め(次近傍): 4つ

LBGK方程式

時間発展は「衝突」と「並進(ストリーミング)」の2段階で記述されます。

右辺第2項はBGK衝突項と呼ばれ、分布関数 が緩和時間 で局所平衡分布 に近づいていく過程を表しています。この が流体の粘性率に関係します。

アルゴリズム

  1. 衝突 (Collision): 各格子点で、分布関数を平衡分布に向かって緩和させます。計算は局所的なので、並列化が極めて容易です。

  2. ストリーミング (Streaming): 衝突後の粒子を、その速度ベクトル に従って隣の格子点へ移動させます。これは単なる配列のインデックスずらしに相当します。

  3. 巨視的変数の計算: 密度 と流速 は、分布関数のモーメント(和)として求まります。

Rustによる実装 (D2Q9)

LBMの実装は驚くほどシンプルです。圧力のポアソン方程式を解く必要がないため、コードの見通しも良くなります。

use ndarray::{Array2, Array3, Axis};

// D2Q9モデルの定数
const W: [f64; 9] = [4./9., 1./9., 1./9., 1./9., 1./9., 1./36., 1./36., 1./36., 1./36.];
// 方向ベクトル e_i (省略)

struct LBMSolver {
    f: Array3<f64>, // (NY, NX, 9)
    rho: Array2<f64>,
    ux: Array2<f64>,
    uy: Array2<f64>,
}

impl LBMSolver {
    // 平衡分布関数の計算
    fn equilibrium(&self, i: usize, rho: f64, ux: f64, uy: f64) -> f64 {
        let cu = 3.0 * (self.ex[i] * ux + self.ey[i] * uy);
        let u2 = ux * ux + uy * uy;
        rho * W[i] * (1.0 + cu + 0.5 * cu * cu - 1.5 * u2)
    }

    fn step(&mut self) {
        let (ny, nx, _) = self.f.dim();
        let mut f_next = Array3::zeros((ny, nx, 9));

        // 1. 衝突 & ストリーミング
        // ストリーミングは、書き込み先のインデックスをずらすことで実装可能
        for y in 0..ny {
            for x in 0..nx {
                // 巨視的変数の計算
                let rho = self.rho[[y, x]];
                let ux = self.ux[[y, x]];
                let uy = self.uy[[y, x]];

                for i in 0..9 {
                    let feq = self.equilibrium(i, rho, ux, uy);
                    // 衝突後の値
                    let f_out = self.f[[y, x, i]] - (self.f[[y, x, i]] - feq) / self.tau;

                    // ストリーミング先の座標
                    let next_x = (x as isize + self.ex[i]).rem_euclid(nx as isize) as usize;
                    let next_y = (y as isize + self.ey[i]).rem_euclid(ny as isize) as usize;

                    f_next[[next_y, next_x, i]] = f_out;
                }
            }
        }

        self.f = f_next;
        // 境界条件処理、巨視的変数の更新など...
    }
}

メリットとデメリット

メリット:

  • アルゴリズムが単純で実装しやすい。
  • 並列化効率が非常に高い(GPU計算向き)。
  • 複雑な境界形状(多孔質媒体など)を扱いやすい。

デメリット:

  • 圧縮性流体や衝撃波の扱いには工夫が必要。
  • メモリ使用量が比較的多い(各格子点で9個以上の変数を保持するため)。

まとめ

本章では、流体力学シミュレーションの2つの主要なアプローチを紹介しました。 差分法による直接解法(MAC法など)は物理的な直感と結びつきやすく、標準的な手法として確立されています。 一方、格子ボルツマン法 (LBM) は、その単純さと並列性から、特に複雑な形状や大規模計算において強力な選択肢となります。

参考リンク

Last change: , commit: 991b48c

統計力学シミュレーション

Important

この章を読む前に

この章を読むには、以下の章を先に読んでおく必要があります。

統計力学は、原子や分子といったミクロな構成要素の振る舞いから、温度や圧力、磁化といったマクロな性質を導き出す学問です。 しかし、現実的な複雑さを持つ系において、理論的に厳密解が得られるケースは極めて稀です。

そこでコンピュータシミュレーションの出番となります。 特にマルコフ連鎖モンテカルロ法 (MCMC) を用いたスピン系のシミュレーションは、計算物理学の中で最も成功した分野の一つであり、相転移や臨界現象の理解に多大な貢献をしてきました。

本章では、統計力学モデルの代名詞である「イジング模型」を題材に、MCMCを用いたシミュレーション手法とその物理的背景を学びます。

本章の構成

  1. イジング模型の基礎 磁性体の簡易モデルであるイジング模型を定義し、統計力学の基本原理(ボルツマン分布)との関係を確認します。

  2. メトロポリス法 MCMCの代表的なアルゴリズムであるメトロポリス法を用いて、2次元イジング模型をRustで実装します。

  3. 相転移とクリティカル現象 温度変化に伴う磁化の発生(自発的対称性の破れ)や比熱の発散といった、相転移現象をシミュレーションで再現・観測します。

  4. 2D Ising実践演習 2次元イジング模型について、厳密解ノート、PLAN、実装、チェック、有限サイズスケーリングを一つの演習として進めます。

  5. その他の格子模型 イジング模型を拡張したポッツ模型やXY模型など、より多様な物理現象を記述するモデルについて概観します。

作業テーマ

作業ディレクトリは ~/rust-computational-physics-work/ からの相対パスです。 ユニットテストで確認する具体的なケースは、解析解、境界条件、許容誤差の観点からAI coding agentと相談して決めます。

テーマ作業ディレクトリ構造化するコードユニットテスト演習拡張演習
イジング模型の基礎statistical-mechanics/ising-basicsidx, neighbor, energy, magnetization小さい格子でエネルギーと磁化を手計算と比較する周期境界条件を切り替えられる形にする
メトロポリス法statistical-mechanics/metropolisdelta_energy_flip, accept, sweep, measure局所エネルギー差と全エネルギー再計算を比較する熱化と測定間隔をmetadataに保存する
相転移statistical-mechanics/phase-transitiontemperature_scan, susceptibility, specific_heat固定seedで測定量の再現性を確認する温度走査結果をCSVに出力する
2D Ising総合演習statistical-mechanics/ising-exercise格子、更新、測定、保存、有限サイズスケーリングを相談して分ける決定的に確認できる性質と小さい格子の統計チェックをAI coding agentと選ぶBinder parameterの交差から転移点を推定し、厳密解と比較する
その他の格子模型statistical-mechanics/other-modelspotts_energy, xy_energy, local_update小さい格子で相互作用エネルギーを確認するmodel enumで複数模型を切り替える

検証と実装の観点

統計力学シミュレーションでは、乱数、熱化、測定間隔、有限サイズ効果を明示します。 MCMC一般では burn-in という語も使われますが、統計物理の文脈では thermalization(熱化)と呼ぶのが一般的です。 1回のrunだけで結論を出さず、seed、格子サイズ、温度、測定回数を結果と一緒に保存します。

  • 格子、model、更新アルゴリズム、測定、入出力をmoduleで分ける。
  • 小さい格子で局所更新量と全エネルギー再計算を比較する。
  • 熱化、sampling interval、autocorrelation、誤差棒を確認する。
  • agentに実装を任せた場合も、周期境界条件とdelta_energy_flipの符号を重点的に確認する。
  • 2D Isingの総合演習では、note、PLAN、実装、チェックの順に進み、Binder parameterで有限サイズスケーリングを確認する。

イジング模型の基礎

統計力学において、イジング模型 (Ising Model) は最も単純かつ最も重要なモデルの一つです。 もともとは強磁性体(磁石)の性質を理解するために考案されましたが、現在では合金の規則化、気体-液体の相転移、さらにはニューラルネットワークのモデルとしても広く応用されています。

モデルの定義

個の格子点(スピン)があり、各スピン (上向き)または (下向き)のいずれかの状態をとるとします。

イジング格子の模式図

系の全エネルギー(ハミルトニアン) は、隣り合うスピン間の相互作用 と、外部磁場 との相互作用によって以下のように定義されます。

ここで は、隣接するスピンのペア(最近接格子点)に対する和を表します。

  • (強磁性): 隣り合うスピンが同じ向き ( または ) になるとエネルギーが下がります(安定化)。これが「揃おうとする力」を生み出し、自発磁化の原因となります。
  • (反強磁性): 隣り合うスピンが逆向きになろうとします。

統計力学の原理

温度 の熱平衡状態において、系がある特定のスピン配置 をとる確率は、ボルツマン分布 (Boltzmann distribution) に従います。

ここで は逆温度( はボルツマン定数)、 は確率の総和を1にするための規格化定数で、分配関数 (Partition Function) と呼ばれます。

この を計算できれば、内部エネルギーや比熱、磁化といったあらゆる熱力学量が求まります。 しかし、スピン数が のとき、可能な配置の総数は となり、少し大きな系(例: でも 通り)では計算不能な大きさになります。

そこで登場するのが、モンテカルロ法で学んだマルコフ連鎖モンテカルロ法 (MCMC) です。 すべての配置を足し合わせるのではなく、ボルツマン分布に従って「確からしい」配置をサンプリングすることで、物理量を近似的に計算します。

総合演習への接続

2D Ising実践演習では、2次元正方格子イジング模型の厳密解について、導出ではなく検証に必要な性質を note にまとめます。 臨界温度、自発磁化、有限サイズでは になる対称性、 の使い分けを整理してから、実装計画に進みます。

参考リンク

Last change: , commit: 656a13f

メトロポリス法

イジング模型のシミュレーションにおいて最も標準的なアルゴリズムが、メトロポリス法 (Metropolis Method) です。 これは、あるスピン配置から別の配置への遷移確率を適切に定めることで、 ボルツマン分布を定常分布にもつマルコフ連鎖を構成する手法です。 詳細釣り合いは、目標分布が定常分布になることを保証する十分条件です。 ただし、任意の初期状態からその分布へ収束するためには、連鎖が状態空間を十分に探索できること、 すなわちエルゴード性も必要です。

アルゴリズムの手順

  1. 初期化: スピン配列を適当な状態(全て やランダムなど)に設定します。

  2. スピンの選択: ランダムに1つのスピン を選びます。

  3. フリップの試行: そのスピンを反転させた状態( )を考え、その時のエネルギー変化 を計算します。

計算に必要なのは局所的な変化分だけです。 正方格子の最近接模型では近傍数が固定なので、 は系の大きさによらず で計算できます。 全エネルギーを毎回再計算すると かかるため、局所更新では避けます。

  1. 遷移判定:

    • もし ならば(エネルギーが下がるか変わらない)、確率1でフリップを採用します。
    • もし ならば(エネルギーが上がる)、確率 でフリップを採用します。 (乱数 を生成し、 なら採用)
  2. 繰り返し: 上記の2〜4を十分な回数繰り返します。

Rustによる実装

2次元イジング模型( )を実装します。周期境界条件を採用します。

use ndarray::{Array2, Axis};
use rand::Rng;
use std::f64;

struct IsingModel {
    size: usize,
    spins: Array2<i32>, // +1 or -1
    j_coupl: f64,       // 相互作用定数 J
    inv_temp: f64,      // 逆温度 beta
}

impl IsingModel {
    fn new(size: usize, beta: f64) -> Self {
        // 初期状態はランダム (高温極限)
        let mut rng = rand::thread_rng();
        let spins = Array2::from_shape_fn((size, size), |_| {
            if rng.gen_bool(0.5) { 1 } else { -1 }
        });

        Self {
            size,
            spins,
            j_coupl: 1.0,
            inv_temp: beta,
        }
    }

    /// メトロポリス法による1ステップ(MCS: Monte Carlo Step)
    /// 1 MCS = 全スピン数回分のフリップ試行
    fn step(&mut self) {
        let mut rng = rand::thread_rng();
        let n_flips = self.size * self.size;

        for _ in 0..n_flips {
            // ランダムにサイトを選択
            let x = rng.gen_range(0..self.size);
            let y = rng.gen_range(0..self.size);
            let s = self.spins[[y, x]];

            // 隣接スピンの和 (周期境界条件)
            // ndarrayのインデックス操作は少し冗長になりがちなので、
            // 実際にはラッパー関数を作ると良いでしょう。
            let xp = if x + 1 == self.size { 0 } else { x + 1 };
            let xm = if x == 0 { self.size - 1 } else { x - 1 };
            let yp = if y + 1 == self.size { 0 } else { y + 1 };
            let ym = if y == 0 { self.size - 1 } else { y - 1 };

            let neighbor_sum = self.spins[[y, xp]]
                + self.spins[[y, xm]]
                + self.spins[[yp, x]]
                + self.spins[[ym, x]];

            // エネルギー変化 Delta E = 2 * s_i * J * sum(s_j)
            // (外部磁場 h=0)
            let delta_e = 2.0 * self.j_coupl * s as f64 * neighbor_sum as f64;

            // 遷移判定
            if delta_e <= 0.0 || rng.gen::<f64>() < (-self.inv_temp * delta_e).exp() {
                self.spins[[y, x]] *= -1; // フリップ採用
            }
        }
    }

    /// 総磁化 (Magnetization)
    fn magnetization(&self) -> f64 {
        self.spins.iter().sum::<i32>() as f64
    }

    /// 総エネルギー
    fn energy(&self) -> f64 {
        let mut e = 0.0;
        for y in 0..self.size {
            for x in 0..self.size {
                let s = self.spins[[y, x]];
                let xp = if x + 1 == self.size { 0 } else { x + 1 };
                let yp = if y + 1 == self.size { 0 } else { y + 1 };
                // 重複カウントを防ぐため、右と下のボンドだけ計算する
                e -= self.j_coupl * s as f64 * (self.spins[[y, xp]] + self.spins[[yp, x]]) as f64;
            }
        }
        e
    }
}

このコードでは、step 関数を呼び出すたびに、平均して各スピンが1回更新される試行(1 Monte Carlo Step, 1 MCS)が行われます。 平衡状態に達するまでの初期過程は、MCMC一般では burn-in と呼ばれることもありますが、 統計物理の文脈では thermalization(熱化)と呼ぶのが一般的です。 物理量は、十分に熱化した後に測定する必要があります。

総合演習への接続

2D Ising実践演習では、Metropolis法の実装に入る前に、model、境界条件、測定量、seed、熱化、測定間隔、検証方法をPLANとして書き出します。 通常のユニットテストでは周期境界条件、全エネルギー、局所エネルギー差、accept判定を決定的に確認し、小さい格子の厳密列挙で統計量の収束も確認します。

参考リンク

Last change: , commit: 656a13f

相転移とクリティカル現象

イジング模型の醍醐味は、単純なルールから相転移 (Phase Transition) という劇的な現象が現れることです。

強磁性相転移

温度を高温から下げていくと、ある臨界温度 を境に、それまでバラバラだったスピンの向きが揃い始め、巨視的な磁化(自発磁化)が発生します。

  • 高温相 ( ): 常磁性相。熱ゆらぎが支配的で、スピンはランダム。総磁化は平均してゼロ。
  • 低温相 ( ): 強磁性相。相互作用が支配的で、スピンが全体として揃う。総磁化は非ゼロ。

磁化-温度曲線(模式図)

オンサーガーの厳密解により、2次元正方格子イジング模型の臨界温度(解析解)は以下のように知られています。

シミュレーションを行うと、この温度付近で様々な物理量が特異な振る舞いを示します。

物理量の測定

シミュレーションで測定すべき主な量は以下の通りです( は総スピン数)。

  1. 単位スピンあたりの平均磁化 :
(有限系では全体が反転する可能性があるため、絶対値をとるのが一般的です)
  1. 単位スピンあたりの比熱 : エネルギーの揺らぎから計算できます(揺動散逸定理)。
  1. 帯磁率 : 磁化の揺らぎから計算できます。

シミュレーション結果の解釈

Rustプログラムで温度を変えながらこれらの量を測定すると、以下の結果が得られます。

  • 磁化 : 低温では1に近く、温度上昇とともに減少し、 付近で急激にゼロに落ちます。
  • 比熱 : 付近で鋭いピーク(発散)を持ちます。これは相転移に伴うエントロピーの急激な変化を表しています。

有限サイズ効果

厳密な相転移(特異点)は、無限大の系 ( ) でのみ起こります。 シミュレーションで扱う有限の系では、相転移は「なめらか」になり、比熱のピークも丸まって、ピーク位置も厳密な から少しずれます。 これを有限サイズ効果と呼びます。

より精密な解析を行うには、システムサイズ を変えながら測定を行い、 への外挿を行う有限サイズスケーリング (Finite Size Scaling) という手法が用いられます。

総合演習への接続

2D Ising実践演習では、Binder parameter

を使って有限サイズスケーリングを調べます。 異なる格子サイズの が交差する温度を、オンサーガーの厳密解

と比較します。 また、低温の有限時間シミュレーションでなぜ がゼロに見えないことがあるのかを、対称性とエルゴード性の観点から考えます。

参考リンク

Last change: , commit: 656a13f

2D Ising実践演習

この演習では、2次元正方格子イジング模型を題材にして、理論的に分かっている性質、実装計画、検証を一つの流れとして扱います。 いきなりコードを書かず、第1章の「AI agent を使う標準的な流れ」と同じく、まず note、次に PLAN、最後に実装とチェックへ進みます。

扱う模型は、外部磁場なし ( ) の強磁性イジング模型です。

ここで は最近接ペアに対する和、 または は強磁性相互作用です。 格子サイズを 、スピン数を 、総磁化を 、単位スピンあたりの磁化を とします。

1. 厳密解ノート

まず、厳密解の導出を追う必要はありません。 実装と検証に必要な性質を、自分の note にまとめます。

外部磁場なしの2次元正方格子イジング模型では、熱力学極限で臨界温度が厳密に分かっています。

したがって、

です。 また、熱力学極限の自発磁化は、

となり、 ではゼロです。 これは有限格子の単純な平均磁化そのものではなく、熱力学極限で対称性が破れた相を選んだときの量です。

有限サイズの分配関数は有限個の指数関数の和なので、自由エネルギーは温度の滑らかな関数です。 したがって、厳密な意味での相転移の特異点は の極限で現れます。 有限サイズのシミュレーションでは、磁化の変化や比熱のピークは丸まり、ピーク位置も厳密な からずれます。

2. 対称性と測定量

では、すべてのスピンを反転する変換 に対してハミルトニアンは不変です。 そのため、有限サイズの平衡分布では となり、厳密にサンプリングすれば

です。 一方で、 はゼロではありません。 低温では分布が の二つの山を持つため、相の大きさは ではなく で見るのが実用的です。

この演習では、以下の量を保存します。

  • 磁化の大きさ:
  • 磁化の2乗:
  • 帯磁率:
  • 比熱:
  • Binder parameter:

Binder parameter は次のように定義します。

Binder parameter は、全磁化 で計算しても単位スピンあたりの磁化 で計算しても同じ値になります。 高温側で は0に近づき、低温側で に近づきます。 異なる は、有限サイズ補正を除いて 付近で交差します。

3. PLAN

実装前に、notes/ising-plan.md のような短い計画を書きます。 AI coding agent に依頼する場合も、最初にこの計画を出させ、人間が確認してから実装へ進みます。

最低限、次を決めます。

  • 模型: 2次元正方格子、周期境界条件、
  • 更新: 単一スピン反転の Metropolis 法
  • 測定: エネルギー、磁化、その揺らぎ、Binder parameter に必要な量
  • 走査: 複数の 、特に の近傍
  • 乱数: seed、熱化 sweep 数、測定 sweep 数、測定間隔
  • 出力: CSV と metadata。seed、 、sweep 数、測定間隔を必ず保存する
  • 検証: 決定的に確認できる性質と、統計的に確認する性質を分ける

関数境界や module 構成は、AI coding agent と相談して決めます。 ただし、物理的な意味がある単位に分け、単に別名を付けるだけの wrapper は避けます。 相談するときは、少なくとも次の責務がどこに入るかを確認します。

  • 格子と周期境界条件
  • エネルギーと磁化の計算
  • 1スピン反転のエネルギー差
  • Metropolis 更新
  • 熱化、測定、統計量の集計
  • 結果保存と metadata
  • Binder parameter と有限サイズスケーリング用の後処理

4. 決定的なユニットテスト

乱数を含むシミュレーションでも、乱数に依存しない部分は決定的にテストできます。 ここではテスト項目を固定せず、AI coding agent と一緒に「どの小さい配置なら手計算できるか」「どの性質が実装ミスを見つけやすいか」を brainstorm します。

候補になる観点は次の通りです。

  • 周期境界条件が正しく実装されているか。
  • 小さい格子のエネルギーと磁化が手計算と一致するか。
  • 1スピン反転のエネルギー差が、反転前後の全エネルギー差と一致するか。
  • Metropolis の採否判定が、エネルギーが下がる場合と上がる場合で正しいか。
  • 固定 seed を使ったとき、短い実行の測定列が再現するか。

実際に cargo test に入れるのは、この中から選んだ少数の本質的なテストにします。 境界条件、エネルギー差の符号、bond の二重数えはバグが入りやすいので、候補として必ず検討します。

5. 統計量の収束チェック

MCMC の結果は乱数を含むため、すべてを通常のユニットテストに押し込むと不安定になります。 決定的なテストと、統計的なチェックを分けます。

小さい格子では全配置を列挙できます。 小さい を選べば配置数 がまだ扱えるので、厳密に

を計算できます。 ここから を求め、Metropolis の長時間平均と比較します。

このチェックは、通常の高速な unit test ではなく、#[ignore] を付けた統計テストや、演習用の検証スクリプトにするのが現実的です。 どの 、温度、seed 数、許容範囲にするかは、実行時間と揺らぎを見ながら AI coding agent と相談して決めます。 複数 seed の平均と標準誤差を保存し、厳密値が誤差棒の範囲に入るかを確認します。 確率的コードの自動テストを厳密に設計したい場合は、仮説検定を使う方法もあります。

6. 有限サイズスケーリング

実装後、 など複数のサイズで温度走査を行います。 まず粗い温度刻みで 近傍を探し、次に から 程度を細かく走査します。

保存した測定量から、次をプロットします。

  • : 低温で非ゼロ、高温でゼロへ向かう。
  • : 付近にピークを持つ。
  • : 付近にピークを持つ。
  • : 異なる の曲線が 付近で交差する。

2次元イジング模型では相関長指数が なので、臨界領域では概念的に

と書けます。 この演習では、厳密なスケーリング崩壊まで要求せず、Binder parameter の交差から転移点を見積もり、

と比較します。 有限サイズ補正、熱化不足、測定間隔の不足、臨界点近傍の autocorrelation の増大が、交点のずれとして現れることも確認します。

発展的な問い

低温で十分長くないシミュレーションを行うと、 がゼロにならないことがあります。 これはハミルトニアンの対称性が消えたからではありません。 有限時間の Markov chain が 側または 側の谷に長く留まり、符号反転をほとんど観測しないためです。 熱力学極限では、二つの相の間を行き来する時間が非常に長くなり、実効的にエルゴード性が破れたように見えます。

発展課題として、低温やスピンガラスのような緩和しにくい系では、複数温度の replica を同時に走らせて配置を交換する replica exchange Monte Carlo(exchange Monte Carlo, parallel tempering)を調べるとよいでしょう。 Hukushima と Nemoto の exchange Monte Carlo は、この考え方をスピンガラスに適用した代表的な仕事です。

参考リンク

Last change: , commit: 656a13f

その他の格子模型

イジング模型はスピンが「上か下か」の2状態のみをとる単純なモデルでしたが、これを拡張することで様々な物理現象を記述できます。

ポッツ模型 (Potts Model)

イジング模型を「 個の状態をとるスピン」に拡張したものです。 各スピン のいずれかの値を持ちます。 ハミルトニアンは以下のように定義されます(クロネッカーのデルタ を使用)。

つまり、隣り合うスピンが「同じ状態」であればエネルギーが下がり、「異なる状態」であればエネルギーは変化しません(あるいは高くなります)。

  • のとき、イジング模型と等価になります。
  • 以上では、相転移の次数が変化する(2次転移から1次転移へ)など、興味深い性質を示します。

XY模型

スピンが2次元ベクトル であるモデルです。 スピンは円周上の任意の角度 をとることができます(連続自由度)。

2次元XY模型は、通常の相転移とは異なるベレゾフスキー・コステリッツ・サウレス (BKT) 転移というトポロジカルな相転移を示すことで有名です(2016年ノーベル物理学賞)。

ハイゼンベルク模型

スピンが3次元ベクトル (単位ベクトル)であるモデルです。 現実の磁性体(鉄など)のモデルとして重要です。

Rustによる実装の工夫

これらの様々なモデルをRustで実装する場合、ジェネリクスやトレイトを活用すると効率的です。

trait LatticeModel {
    type Spin; // 状態の型 (i32, f64, Vector3など)
    
    // エネルギー計算
    fn calculate_energy(&self, s1: &Self::Spin, s2: &Self::Spin) -> f64;
    
    // スピン更新(メトロポリス法の試行)
    fn propose_update(&self, current: &Self::Spin) -> Self::Spin;
}

このように共通のインターフェースを定義しておけば、モンテカルロ法のメインループ(遷移判定など)を共通化し、モデル部分だけを差し替えてシミュレーションを行うことができます。 これはRustの強力な型システムの利点が活きる場面です。

Last change: , commit: 9d9d1d8

量子力学シミュレーション

Important

この章を読む前に

この章を読むには、以下の章を先に読んでおく必要があります。

量子力学は、原子や分子、半導体デバイスなど、微細なスケールの世界を支配する物理法則です。 その中心となるシュレーディンガー方程式は、解析的に解ける場合が非常に限られているため、数値シミュレーションが不可欠な分野です。

本章では、量子力学の基本方程式をコンピュータ上で解くための手法を学び、いくつかの代表的な量子現象をシミュレーションします。

本章の構成

  1. シュレーディンガー方程式の数値解法 連続的な波動関数を離散化し、ハミルトニアンを行列として表現する方法(差分法)を学びます。また、Rustにおける複素数の扱い方も紹介します。

  2. 1次元束縛状態 時間的に変化しない定常状態(固有状態)を求める問題を扱います。井戸型ポテンシャルなどを例に、エネルギー準位の計算を行います。

  3. 時間発展とスプリット演算子法 波動関数の時間変化を追跡するための手法(クランク・ニコルソン法、スプリット演算子法)について解説します。

  4. 散乱問題とトンネル効果 ポテンシャル障壁に対する波束の衝突をシミュレーションし、量子力学特有の現象であるトンネル効果を観測します。

作業テーマ

作業ディレクトリは ~/rust-computational-physics-work/ からの相対パスです。 ユニットテストで確認する具体的なケースは、解析解、境界条件、許容誤差の観点からAI coding agentと相談して決めます。

テーマ作業ディレクトリ構造化するコードユニットテスト演習拡張演習
Schrodinger方程式quantum-mechanics/schrodingerbuild_grid, potential, hamiltonian小さい格子でHamiltonianの対称性を確認するgrid spacingと境界条件をmetadataに保存する
1次元束縛状態quantum-mechanics/bound-statessolve_eigenstates, normalize, expectation_energy井戸型ポテンシャルの低い準位を確認する固有状態とエネルギーをCSVに出力する
時間発展quantum-mechanics/time-evolutionstep_crank_nicolson, step_split_operator, normnorm保存を許容誤差つきで確認するtime stepを変えたnorm driftを比較する
散乱問題quantum-mechanics/scatteringwave_packet, barrier, transmission_probability初期波束の規格化を確認する透過率の障壁幅依存をCSVに出力する

検証と実装の観点

量子力学の数値計算では、複素数、境界条件、規格化、単位系を明示します。 固有値や時間発展の結果は、見た目だけでなく、norm、energy、residualで確認します。

  • grid、potential、Hamiltonian、solver、出力処理を分けて実装する。
  • 固有値問題では residual、時間発展では norm保存を確認する。
  • grid spacing、time step、境界条件、初期波束、potential parameterをmetadataとして残す。
  • agentに実装を任せた場合も、複素共役、正規化、境界条件の扱いをdiffで確認する。

シュレーディンガー方程式の数値解法

量子力学の世界を記述する基本方程式は、シュレーディンガー方程式 (Schrödinger Equation) です。 本節では、これをコンピュータで扱うためにどのように離散化するか、すなわち行列形式への変換について学びます。

支配方程式

1粒子の波動関数 に対する方程式は以下の通りです。

時間依存シュレーディンガー方程式 (TDSE)

時間非依存シュレーディンガー方程式 (TISE)

エネルギー固有状態(定常状態)を求めるための固有値方程式です。

ここで はハミルトニアン演算子、 はポテンシャルエネルギー、 はプランク定数(ディラック定数)です。 数値計算では、通常 となる単位系(原子単位系など)を採用して式を簡略化します。

空間の離散化(差分法)

1次元空間 を格子間隔 で離散化し、格子点 上の波動関数の値を とします。 運動エネルギー項(2階微分)を第2中心差分で近似すると以下のようになります。

これを用いると、ハミルトニアン の第 成分は次のように書けます( とする)。

これを整理すると、隣接する3点に関係する式となります。

行列形式

上記の式は、ハミルトニアンを行列 、波動関数をベクトル と見なせば、行列ベクトル積として表現できます。

ここで、

  • 対角成分:
  • 非対角成分:

このように、連続的な演算子であったハミルトニアンは、離散化によって三重対角行列 (Tridiagonal Matrix) に変換されます。 これにより、量子力学の問題は線形代数の問題(固有値問題や連立一次方程式)に帰着されます。

Rustにおける複素数の扱い

量子力学では複素数が必須です。Rustには標準で複素数型が含まれていないため、num-complex クレートを使用します。

[dependencies]
num-complex = "0.4"
use num_complex::Complex64; // 複素数型 (実部・虚部がf64)

fn multiply_by_i(z: Complex64) -> Complex64 {
    z * Complex64::i()
}

fn main() {
    let z = Complex64::new(1.0, 2.0); // 1 + 2i
    let result = multiply_by_i(z);

    println!("z * i = {}", result); // (-2 + 1i)
}

次節以降、この行列形式と複素数型を用いて具体的な問題を解いていきます。

Last change: , commit: ec78068

1次元束縛状態

定常状態のシュレーディンガー方程式 を解くことは、離散化されたハミルトニアン行列の固有値問題を解くことと等価です。

  • 固有値 : エネルギー準位
  • 固有ベクトル : そのエネルギーに対応する定常状態の波動関数

例題:井戸型ポテンシャル

の領域 内では 、外側では となる井戸型ポテンシャルを考えます。 外側のポテンシャル が有限であれば波動関数は壁の外側にわずかに染み出し、無限大であれば境界で完全にゼロとなります。

有限井戸型ポテンシャルと波動関数

離散化する場合、境界条件として両端で波動関数がゼロ( )となるようにします。

固有値計算の実装(虚時間発展法)

行列の全ての固有値を求めるには高度なアルゴリズム(QR法など)が必要ですが、最小の固有値(基底状態) だけなら、ハミルトニアンを波動関数に作用させる反復法で求められます。

この節のコード例では、逆行列を明示的に解く逆べき乗法ではなく、虚時間発展法 (Imaginary Time Evolution) を使います。時間を と置き換えると、高いエネルギー成分が指数関数的に減衰するため、反復と正規化を繰り返すことで基底状態へ近づきます。

Rustコード例

use ndarray::{Array1, Array2, Axis};
use ndarray_linalg::Solve; // 連立方程式を解くために必要 (実際には ndarray-linalg 推奨)

// 注: 以下のコードは概念説明用です。
// ndarray単体には逆行列や連立方程式ソルバーが含まれていないため、
// 本格的な計算には `ndarray-linalg` クレートの導入を推奨します。
// ここでは、アルゴリズムの流れを示します。

struct QuantumSystem {
    n_grid: usize,
    dx: f64,
    potential: Array1<f64>,
}

impl QuantumSystem {
    fn new(n: usize, l: f64) -> Self {
        Self {
            n_grid: n,
            dx: l / (n as f64),
            potential: Array1::zeros(n), // 自由粒子 (V=0)
        }
    }

    // ハミルトニアン行列とベクトルの積 H * psi を計算
    fn apply_hamiltonian(&self, psi: &Array1<f64>) -> Array1<f64> {
        let n = self.n_grid;
        let mut h_psi = Array1::zeros(n);
        let coeff = -0.5 / (self.dx * self.dx);

        for i in 1..n-1 {
            // H = K + V
            // Kinetic part: -1/2 * (psi[i+1] - 2psi[i] + psi[i-1]) / dx^2
            let kinetic = coeff * (psi[i+1] - 2.0 * psi[i] + psi[i-1]);
            let potential = self.potential[i] * psi[i];
            h_psi[i] = kinetic + potential;
        }
        h_psi
    }
    
    // レイリー商によるエネルギー期待値の計算 <psi|H|psi> / <psi|psi>
    fn expected_energy(&self, psi: &Array1<f64>) -> f64 {
        let h_psi = self.apply_hamiltonian(psi);
        psi.dot(&h_psi) / psi.dot(psi)
    }
}

// 虚時間発展法 (Imaginary Time Evolution)
// 基底状態を求めるための強力な手法。
// d/dt |psi> = -H |psi> という拡散方程式を解くと、
// 時間とともにエネルギーの高い成分が減衰し、基底状態だけが残る。
fn find_ground_state(sys: &QuantumSystem) -> Array1<f64> {
    let mut psi = Array1::from_elem(sys.n_grid, 1.0); // 初期状態
    let dt = 0.001; // 虚時間の刻み
    
    // 単純なオイラー法での更新: psi(t+dt) = psi(t) - dt * H * psi(t)
    // (実際には正規化が必要)
    for _ in 0..10000 {
        let h_psi = sys.apply_hamiltonian(&psi);
        psi = &psi - &(&h_psi * dt);
        
        // 正規化
        let norm = psi.dot(&psi).sqrt();
        psi.mapv_inplace(|x| x / norm);
    }
    psi
}

fn main() {
    let sys = QuantumSystem::new(100, 1.0);
    let ground_state = find_ground_state(&sys);
    let energy = sys.expected_energy(&ground_state);
    
    println!("Ground state energy: {:.4}", energy);
    // 理論値 E1 = pi^2 / 2 approx 4.935 (L=1, m=1, hbar=1)
}

虚時間発展法

上記のコード例では、虚時間発展法 (Imaginary Time Evolution) を使用しました。 シュレーディンガー方程式の時間を と置換すると、拡散方程式になります。

解は となり、 で最小のエネルギー を持つ項(基底状態)以外は指数関数的に減衰して消滅します。 これは基底状態を求めるための非常に汎用的で強力な手法です。

参考リンク

Last change: , commit: 991b48c

時間発展とスプリット演算子法

波動関数の時間変化 を追跡することは、化学反応のダイナミクスや電子デバイスの動作解析において重要です。

クランク・ニコルソン法

時間依存シュレーディンガー方程式 を時間積分する際、単純なオイラー法を使うと、波動関数のノルム(全存在確率)が保存されず、発散してしまいます。 量子力学のシミュレーションでは、時間発展演算子がユニタリである(ノルムを保存する)ことが必須です。

クランク・ニコルソン法 (Crank-Nicolson Method) は、陰解法の一種で、ユニタリ性と2次の精度を持ち、無条件安定です。

あるいは、

これを解くには、各ステップで連立一次方程式 を解く必要があります。ここで行列 は三重対角行列になるため、トーマス法(TDMA)を使って で高速に解くことができます。

スプリット演算子法 (Split-Operator Method)

より高速で高精度な手法として、スプリット演算子法があります。 ハミルトニアンを運動エネルギー とポテンシャル に分け、時間発展演算子 を近似的に分解します(トロッター分解)。

  • は座標空間では単なる位相回転(対角行列)です。
  • は運動量空間では単なる位相回転です。

したがって、以下の手順で計算できます。

  1. 座標空間 を掛ける。
  2. 高速フーリエ変換 (FFT) で運動量空間へ移行。
  3. 運動量空間 を掛ける。
  4. 逆高速フーリエ変換 (IFFT) で座標空間へ戻る。
  5. 座標空間 を掛ける。

FFTを利用するため、計算量は となり非常に高速です。

Rustによる実装方針

スプリット演算子法を実装するには、フーリエ解析で紹介した rustfft クレートを使用します。

// 擬似コード
fn step(psi: &mut Vec<Complex64>) {
    // 1. Potential half-step
    apply_potential(psi, 0.5 * dt);

    // 2. FFT
    fft_planner.process(psi);

    // 3. Kinetic step (in k-space)
    apply_kinetic_in_k_space(psi, dt);

    // 4. IFFT
    ifft_planner.process(psi);

    // 5. Potential half-step
    apply_potential(psi, 0.5 * dt);
}

この手法は、ガウス波束の運動やトンネル効果のシミュレーションなどに非常に適しています。 次節では、これを用いてトンネル効果を見てみましょう。

参考リンク

Last change: , commit: 991b48c

散乱問題とトンネル効果

量子力学特有の現象として最も有名なのがトンネル効果 (Quantum Tunneling) です。 古典力学では乗り越えられないエネルギーの障壁を、量子的な粒子は確率的に透過してしまう現象です。

シミュレーション設定

  • 初期状態: ガウス波束(ある平均運動量 を持って右へ進む粒子)
  • ポテンシャル: 経路の途中に壁(ポテンシャル障壁 )を設置

トンネル効果の概念図

Rustによる実装(簡易版)

ここでは、波束・障壁・確率密度の形を見るために、簡易的な陽的 Euler 法を用いたデモコードを示します。陽的 Euler 法はシュレーディンガー方程式のユニタリ性を保存せず、任意の有限な で長時間安定な時間発展にはなりません。本格的には前節のスプリット演算子法かクランク・ニコルソン法を使用してください。

use num_complex::Complex64;

const N: usize = 200;
const L: f64 = 100.0;
const DT: f64 = 0.05;
const DX: f64 = L / N as f64;

fn gaussian_wave_packet(x0: f64, sigma: f64, k0: f64) -> Vec<Complex64> {
    (0..N).map(|i| {
        let x = i as f64 * DX;

        // exp(-(x-x0)^2 / 2sigma^2) * exp(i k0 x)
        let envelope = (-(x - x0).powi(2) / (2.0 * sigma.powi(2))).exp();
        let phase = Complex64::from_polar(1.0, k0 * x);
        Complex64::new(envelope, 0.0) * phase
    }).collect()
}

fn barrier_potential(start: f64, width: f64, height: f64) -> Vec<f64> {
    (0..N).map(|i| {
        let x = i as f64 * DX;
        if x > start && x < start + width { height } else { 0.0 }
    }).collect()
}

fn explicit_euler_step(psi: &[Complex64], potential: &[f64]) -> Vec<Complex64> {
    // psi(t+dt) = psi(t) - i * dt * H * psi(t)
    let mut next_psi = psi.to_vec();

    for i in 1..N - 1 {
        let kinetic = -0.5 * (psi[i + 1] - 2.0 * psi[i] + psi[i - 1]) / (DX * DX);
        let potential_energy = potential[i] * psi[i];
        let h_psi = kinetic + potential_energy;

        next_psi[i] = psi[i] - Complex64::i() * DT * h_psi;
    }

    next_psi
}

fn max_probability(psi: &[Complex64]) -> f64 {
    psi.iter().map(|z| z.norm_sqr()).fold(0.0_f64, f64::max)
}

fn evolve_scattering(
    mut psi: Vec<Complex64>,
    potential: &[f64],
    n_steps: usize,
    sample_interval: usize,
) -> (Vec<Complex64>, Vec<(usize, f64)>) {
    let mut samples = Vec::new();

    // 時間発展ループ
    for t in 0..n_steps {
        if t % sample_interval == 0 {
            // ここで |psi|^2 を出力してプロットすると、波束の動きが見える
            samples.push((t, max_probability(&psi)));
        }

        // オイラー法 (不安定なので注意)
        psi = explicit_euler_step(&psi, potential);
    }

    (psi, samples)
}

fn main() {
    // 波動関数の初期化 (ガウス波束)
    let x0 = L / 4.0;
    let sigma: f64 = 5.0;
    let k0 = 2.0; // 平均運動量
    let psi = gaussian_wave_packet(x0, sigma, k0);

    // ポテンシャル障壁
    let potential = barrier_potential(L / 2.0, 5.0, 1.5);
    let (_psi_final, samples) = evolve_scattering(psi, &potential, 200, 20);

    for (step, probability) in samples {
        println!("Step {}: max probability = {:.4}", step, probability);
    }
}

結果の観察

このシミュレーションを実行すると、波束が壁に衝突した際、一部が反射し、一部が壁を通り抜けて透過していく様子が観察できます。 壁の高さが粒子のエネルギーよりも高くても、透過波が存在することが確認できます。

透過係数と反射係数

規格化された波動関数に対して、壁の向こう側(透過領域)における確率密度の積分値が透過係数 (Transmission Coefficient) です。

離散格子では、透過領域の を足し合わせ、最後に を掛けて近似します。十分に時間が経過した後、この は定数値に収束します。解析解と比較することで、シミュレーションの精度を検証できます。

参考リンク

Last change: , commit: 991b48c

並列計算

Important

この章を読む前に

この章を読むには、以下の章を先に読んでおく必要があります。

また、並列化の具体例として第2部・第3部の内容を使用するため、それらの章を読んでおくとより実践的な理解が得られます。

物理シミュレーションの規模が大きくなると、単一のCPUコア(スレッド)での計算には限界が訪れます。 現代のコンピュータはマルチコア化が進んでおり、計算資源を最大限に活用するためには並列計算 (Parallel Computing) が不可欠です。

Rustは「恐れを知らない並列性 (Fearless Concurrency)」を標榜しており、所有権と型システムによって、データ競合 (Data Race) などの並列プログラミングにおける典型的なバグをコンパイルタイムで防ぐことができます。 本章では、数値計算を高速化するための並列化手法と、そのパフォーマンスを評価する方法を学びます。

本章の構成

  1. Rayonによるデータ並列化 Rustで最も広く使われている並列計算ライブラリ Rayon を使い、既存のループ処理をいかに簡単に並列化できるかを学びます。

  2. SIMD最適化 CPUの特殊な命令セットを用いて、一度に複数のデータを処理する SIMD (Single Instruction, Multiple Data) について解説します。

  3. パフォーマンス測定とプロファイリング 最適化の指針を得るために、ベンチマークの取り方やボトルネックの特定方法を学びます。

  4. GPU計算への展望 さらに大規模な並列計算を可能にする GPU (GPGPU) 計算の基本概念と、Rustにおける展望を紹介します。

Rayonによるデータ並列化

Rustにおいて、マルチコアCPUを活用した並列処理を最も手軽に、かつ安全に導入できるのが Rayon クレートです。 Rayonは「データ並列性 (Data Parallelism)」に特化しており、イテレータを並列化するだけで劇的な速度向上が期待できます。

Rayonの導入

Cargo.toml に追加します。

[dependencies]
rayon = "1.11"

並列イテレータの使い方

Rayonの最大の特徴は、標準ライブラリのイテレータ (iter(), iter_mut()) を par_iter(), par_iter_mut() に書き換えるだけで、自動的にスレッドプールへのタスク割り当て(ワークスティーリング)が行われる点です。

Rayonによるデータ並列化のイメージ

実践例:モンテカルロ法による円周率の推定

並列化の効果が分かりやすい例として、大量の乱数を用いた円周率の計算を考えます。

use rand::Rng;
use rayon::iter::{IntoParallelIterator, ParallelIterator};

fn estimate_pi(n_samples: usize) -> f64 {
    // 各サンプルが独立しているため、並列化が非常に容易
    let count: usize = (0..n_samples)
        .into_par_iter() // 並列イテレータに変換
        .map(|_| {
            let mut rng = rand::thread_rng();
            let x: f64 = rng.gen();
            let y: f64 = rng.gen();
            if x * x + y * y <= 1.0 { 1 } else { 0 }
        })
        .sum();

    4.0 * count as f64 / n_samples as f64
}

fn main() {
    let n = 10_000_000;
    let start = std::time::Instant::now();
    let pi = estimate_pi(n);
    let duration = start.elapsed();

    println!("Estimated Pi: {}, Time: {:?}", pi, duration);
}

この例では、into_par_iter() を使うことで、数千万回のループがCPUコア数に合わせて自動的に分割・実行されます。計算量が多いほど、並列化による恩恵が顕著になります。

数値計算への応用:多粒子系の力計算

第11章の分子動力学などで、全粒子ペアに対して力を計算する場面を考えます。

use ndarray::{Array2, Axis};
use rayon::iter::{IntoParallelIterator, ParallelIterator};

fn compute_forces_parallel(positions: &Array2<f64>, forces: &mut Array2<f64>) {
    let n = positions.nrows();

    // 各粒子の力を独立に計算(実際には作用反作用の利用などで工夫が必要)
    // ここでは各粒子の更新ループを並列化する例
    forces.axis_iter_mut(Axis(0))
          .into_par_iter()
          .enumerate()
          .for_each(|(i, mut force_i)| {
              for j in 0..n {
                  if i == j { continue; }
                  // 粒子 j から i に働く力を計算して force_i に加算
              }
          });
}

なぜ安全なのか?

Rustの型システム(SendSync トレイト)が、スレッド間で共有してはいけないデータの受け渡しをコンパイル時に拒否します。 例えば、複数のスレッドから同時に同じメモリ領域を可変参照しようとするとコンパイルエラーになるため、Rayonを使っている限りデータ競合を心配する必要がありません。

注意点:オーバーヘッド

並列化すれば必ず速くなるわけではありません。

  • データ量が少ない場合: スレッドの生成やタスク分割のコストが、計算の短縮分を上回ることがあります。
  • 計算内容が軽すぎる場合: 各タスクの実行時間が短すぎると、管理コストが支配的になります。

まずはプロファイリングを行い、計算負荷の高いループを見極めることが重要です。

Last change: , commit: d65f323

SIMD最適化

SIMD (Single Instruction, Multiple Data) は、1つのCPU命令で複数のデータを同時に処理するハードウェア機能です。 現代のCPUには、x86のAVX/AVX-512やArmのNEONといったSIMD命令セットが搭載されており、これらを活用することで、演算性能を数倍に高めることができます。

SIMDの概念

通常の演算(Scalar)が 1 + 1 を行うのに対し、SIMDでは例えば 8個の浮動小数点数を一度に足し合わせます。

Scalar演算とSIMD演算の比較

RustでのSIMD

RustでSIMDを利用するには、主に以下の3つのアプローチがあります。

1. オートベクタライズ (Auto-vectorization)

コンパイラ (LLVM) がコードを解析し、可能であれば自動的にSIMD命令を生成します。 開発者は特別なコードを書く必要はありませんが、ループの構造やメモリの連続性に配慮する必要があります。

2. クレートの利用 (ndarrayなど)

ndarray などの数値計算ライブラリは、内部でSIMDを活用した最適化が行われています。ライブラリのメソッド(dot など)を使うだけで、恩恵を受けられます。

3. 明示的なSIMD (std::simd)

RustのNightlyチャンネルで開発されている portable-simd を使うと、ポータブルな(CPUアーキテクチャに依存しない)形でSIMDコードを記述できます。

// Nightly Rustが必要
#![feature(portable_simd)]
use std::simd::f64x4;

fn main() {
    let a = f64x4::from_array([1.0, 2.0, 3.0, 4.0]);
    let b = f64x4::from_array([5.0, 6.0, 7.0, 8.0]);
    
    // 一度の命令で4つの加算が行われる
    let c = a + b;
    
    assert_eq!(c.to_array(), [6.0, 8.0, 10.0, 12.0]);
}

SIMDとメモリ配置

SIMDの性能を引き出すには、データがメモリ上に連続して配置されていること (Contiguous Memory Layout) が不可欠です。

計算物理においては、粒子データの「構造体の配列 (AoS: Array of Structures)」を「配列の構造体 (SoA: Structure of Arrays)」に組み替えることで、SIMDが効きやすくなることがよくあります。

AoS vs SoA のコード例

// AoS: 直感的な構造体

struct Particle {

    x: f64, y: f64, z: f64,

    vx: f64, vy: f64, vz: f64,

}

let particles: Vec<Particle> = vec![...];



// SoA: SIMDに優しい構造

struct ParticlesSoA {

    x: Vec<f64>,

    y: Vec<f64>,

    z: Vec<f64>,

}

let p_soa = ParticlesSoA {

    x: vec![1.0, 2.0, ...],

    y: vec![3.0, 4.0, ...],

    z: vec![5.0, 6.0, ...],

};



// SoAでの計算例(コンパイラがオートベクタライズしやすい)

fn update_positions(p: &mut ParticlesSoA, dt: f64) {

    p.x.iter_mut().zip(&p.vx).for_each(|(x, vx)| *x += vx * dt);

    // x方向の演算、y方向の演算がそれぞれ連続したメモリ領域で行われるため

    // 1つのSIMD命令で8個程度の粒子を同時に処理できる

}

SoA形式にすると、同じ座標軸のデータを一気にSIMDレジスタにロードできるため、CPUのキャッシュ効率と演算効率が大幅に向上します。

物理シミュレーションを極限まで高速化したい場合、このデータ構造の設計が鍵となります。

Last change: , commit: 9d9d1d8

パフォーマンス測定とプロファイリング

最適化の第一歩は、プログラムのどこが遅いのかを正確に把握することです。 数値計算プログラムにおいて、勘に頼った最適化は往々にして失敗します。

ベンチマーク:Criterion

Rustで精密なベンチマークを取るための標準的なライブラリが Criterion です。 実行時間の統計的なばらつきを考慮し、微小な変化を正確に測定できます。

Cargo.toml に設定を追加し、benches/my_benchmark.rs を以下のように記述します。

[dev-dependencies]
criterion = { version = "0.8", features = ["html_reports"] }

[[bench]]
name = "my_benchmark"
harness = false
use criterion::{black_box, criterion_group, criterion_main, Criterion};

fn heavy_computation(n: u64) -> u64 {
    (0..n).sum()
}

fn criterion_benchmark(c: &mut Criterion) {
    c.bench_function("sum 1000", |b| b.iter(|| {
        heavy_computation(black_box(1000))
    }));
}

criterion_group!(benches, criterion_benchmark);
criterion_main!(benches);

cargo bench を実行すると、詳細な統計レポートが target/criterion/ に生成されます。black_box を使うことで、コンパイラによる過度な最適化(計算自体を消し去ること)を防ぎ、正しく測定を行うことができます。

プロファイリング:Flamegraph

プログラムの実行時間のうち、どの関数が何パーセントを占めているかを視覚化するのが フレームグラフ (Flamegraph) です。

Rustでは cargo-flamegraph を使うことで、簡単に生成できます。

cargo install cargo-flamegraph
cargo flamegraph -- [プログラムの引数]

生成された flamegraph.svg をブラウザで開くと、横幅が実行時間に相当するスタックトレースが表示されます。 幅の広い(=時間がかかっている)関数を特定し、そこを集中的に最適化するのが鉄則です。

最適化のステップ

  1. リリースモードで実行: 常に cargo run --release で測定してください。デバッグモードとは速度が数十倍〜数百倍異なります。
  2. ボトルネックの特定: フレームグラフで最も重い関数を見つける。
  3. アルゴリズムの改善: オーダー ( から へなど)を改善できないか検討する。
  4. 並列化・SIMD化: ボトルネックとなっているループを RayonSIMD で高速化する。
  5. 再測定: 効果を確認する。
Last change: , commit: 2413f43

GPU計算への展望

物理シミュレーションの規模が極めて大きくなった場合、数千個のコアを持つ GPU (Graphics Processing Unit) を用いた計算(GPGPU)が威力を発揮します。

GPU計算の特徴

CPUが少数の高性能なコアで逐次的な複雑な処理を得意とするのに対し、GPUは大量の単純なコアで同じ操作を一斉に実行することを得意とします。 これを SIMT (Single Instruction, Multiple Threads) と呼びます。

LBM(格子ボルツマン法)や分子動力学、深層学習などの「格子」や「粒子」を扱うアルゴリズムはGPUと非常に相性が良いです。

RustにおけるGPUエコシステム

RustからGPUを利用するためのライブラリは急速に進化しています。

1. wgpu

WebGPU標準に基づく、ポータブルなGPU抽象化レイヤーです。 ゲームエンジンでの利用が主ですが、計算シェーダー(Compute Shader)を用いることで、高度な数値計算も可能です。

2. rust-gpu (spirv-builder)

RustそのものでGPU上で動くコード(シェーダー)を書き、コンパイルすることを目指すプロジェクトです。 従来は GLSL や HLSL といった特殊な言語で書く必要があったシェーダーを、Rustの型安全性を活かして記述できます。

3. CUDA (cudarc)

NVIDIAのGPUに特化した計算プラットフォーム CUDA をRustから利用するためのクレートです。 既存のCUDAカーネルを呼び出したり、Rustから動的に構築したりできます。

これからの学習に向けて

GPU計算は、メモリ転送のコストやスレッド間の同期など、CPUでの並列計算とは異なる特有の難しさがあります。 しかし、Rustのメモリ安全性のパラダイムは、GPUプログラミングにおいてもリソース管理のミスを減らす強力な武器になります。

本書で学んだ数値計算の基礎は、GPUという強力なハードウェア上でも形を変えて受け継がれます。 より大規模なシミュレーションに挑む際は、これらのGPU計算ライブラリのドキュメントを紐解いてみてください。

Last change: , commit: 413013a

付録A: 参考資料

Last change: , commit: 49d8557

付録B: 有用なクレート集

Last change: , commit: 49d8557

付録C: デバッグとトラブルシューティング

Last change: , commit: 49d8557

付録D: 数学的背景

Last change: , commit: 49d8557

付録E: 高精度演算(double-double型とxprec-rs)

標準の64ビット浮動小数点数 f64 は、ほとんどの科学技術計算において十分な精度と広い表現範囲を提供します。しかし、浮動小数点演算と誤差で見たように、浮動小数点演算には常に丸め誤差や桁落ちといった問題が伴います。特に、下記のようなケースでは f64 の精度では不十分となり、計算結果に大きな誤差が生じることがあります。

  • 条件数の悪い問題: 行列計算などで、入力の小さな変化が出力に大きな影響を与える問題。
  • カオス系のシミュレーション: 初期値鋭敏性を持つため、初期の微小な誤差が時間とともに指数関数的に増大する。
  • 長時間のシミュレーション: 繰り返し計算の過程で丸め誤差が累積し、無視できない大きさになる。

このような場合、標準の f64 よりも高い精度を持つ数値型が必要になります。本節では、その一つの解決策である double-double演算 と、それをRustで実現するためのクレート xprec を紹介します。

double-double演算の仕組み

double-double演算は、一つの数値を二つの f64 の和として表現することで、実質的により高い精度を実現する手法です。

  • 表現: ある数値 を、 のように表現します。
    • は数値の主要部分(上位ワード)を表す f64 です。
    • で表現しきれなかった微小な部分(下位ワード)を表す f64 です。

この方法により、f64 の仮数部が53ビットであるのに対し、double-double型では実質的に106ビットの仮数部を持つことになり、有効桁数を約2倍(10進数で約15桁から約31桁へ)に増やすことができます。

これは、IEEE 754で規格化されている四倍精度浮動小数点数とは異なり、既存の倍精度浮動小数点数の演算機能を組み合わせてソフトウェア的に高精度を実現する手法です。そのため、特別なハードウェアを必要としない利点があります。

xprecクレートによる高精度計算

xprec は、このdouble-double演算を効率的に行うためのRustクレートです。f64 とほぼ同じように扱える Df64 型を提供します。

xprecの導入

まず、Cargo.toml[dependencies] セクションに xprec を追加します。

[dependencies]
xprec = "0.2"

Df64型の基本的な使い方

xprec の中心となるのは Df64 型です。f64 と同様の演算子(+, -, *, /)が実装されており、直感的に使用することができます。

use xprec::Df64;

fn main() {
    // f64からDf64への変換
    let a = Df64::from(1.0);
    let b = Df64::from(2.0);

    // 基本的な四則演算
    let sum = a + b;
    let diff = a - b;
    let prod = a * b;
    let quot = a / b;

    // Df64はDisplayトレイトを実装している
    println!("a + b = {}", sum);
    println!("a - b = {}", diff);
    println!("a * b = {}", prod);
    println!("a / b = {}", quot);

    // Df64からf64への変換(上位ワードを取得)
    let sum_f64: f64 = sum.hi();
    println!("Sum as f64: {}", sum_f64);
}

Note

Df64::from(x) の動作について

Df64::from(x)f64 の値をそのまま上位ワードとして使用し、下位ワードを0に設定します。そのため、f64 の時点で既に丸め誤差が含まれている値(例: 0.1)は、Df64 に変換しても誤差は解消されません。Df64 の真価は、高精度な値同士の演算において誤差の累積を抑制できる点にあります。

実例: 総和計算における誤差累積の軽減

Df64 の有用性を示す具体例として、Basel問題(バーゼル問題)を取り上げます。これは、自然数の逆数の二乗和を求める問題で、解析解が であることが知られています。

この級数を有限項で打ち切って計算すると、f64 では項数が増えるにつれて丸め誤差が累積し、精度が悪化します。一方、Df64 を使用すると、誤差の累積を大幅に抑制できます。

use xprec::Df64;

fn main() {
    let n: u64 = 100_000_000; // 1億項
    let exact = std::f64::consts::PI.powi(2) / 6.0;

    let mut sum_f64: f64 = 0.0;
    let mut sum_df64: Df64 = Df64::ZERO;

    for i in 1..=n {
        let i_f64 = i as f64;
        let term = 1.0 / (i_f64 * i_f64);
        sum_f64 += term;
        sum_df64 = sum_df64 + Df64::from(term);
    }

    // 級数の打ち切り誤差を補正(1/N で近似)
    let correction = 1.0 / (n as f64);
    sum_f64 += correction;
    sum_df64 = sum_df64 + Df64::from(correction);

    println!("解析解:       {:.16}", exact);
    println!("f64  の結果:  {:.16}", sum_f64);
    println!("Df64 の結果:  {:.16}", sum_df64.hi());
    println!("f64  の誤差:  {:.2e}", (sum_f64 - exact).abs());
    println!("Df64 の誤差:  {:.2e}", (sum_df64.hi() - exact).abs());
}

実行結果(N = 10^8 の場合):

解析解:       1.6449340668482264
f64  の結果:  1.6449340678345750
Df64 の結果:  1.6449340668482264
f64  の誤差:  9.86e-10
Df64 の誤差:  0.00e+00

この結果から、1億回の加算を行った場合、f64 では約10桁目に誤差が現れる(誤差 ≈ 10^-10)のに対し、Df64 では誤差がほぼ0に抑えられていることがわかります。これは、長時間のシミュレーションや大規模な数値計算において、Df64 が非常に有効であることを示しています。

数学関数の利用

xprecsimba クレートのトレイトを実装しており、sqrt, sin, cos, exp などの数学関数や、π などの定数を利用できます。これらを使用するには、対応するトレイトをインポートする必要があります。

use xprec::Df64;
use simba::scalar::{ComplexField, RealField};

fn main() {
    let x = Df64::from(2.0);

    // 平方根
    let sqrt_x = x.sqrt();
    println!("sqrt(2) = {}", sqrt_x);

    // べき乗
    let x_cubed = x.powi(3);
    println!("2^3 = {}", x_cubed);

    // 高精度のπ
    let pi: Df64 = Df64::pi();
    println!("π (Df64) = {}", pi);
    println!("π (f64)  = {}", std::f64::consts::PI);

    // 三角関数
    let sin_pi = pi.sin();
    println!("sin(π) = {}", sin_pi);
}

Important

トレイトのインポートについて

数学関数を使用するには simba クレートのトレイトをインポートする必要があります。Cargo.tomlsimba を追加するか、xprec が依存している simba を利用してください。

[dependencies]
xprec = "0.2"
simba = "0.9"

利用上の注意点

double-double演算は強力ですが、万能ではありません。利用する際には以下の点に注意が必要です。

  • パフォーマンス: Df64 の演算は、複数の f64 演算を組み合わせて実行されるため、ネイティブの f64 演算よりも遅くなります(およそ15倍程度)。そのため、プログラム全体を Df64 に置き換えるのではなく、精度が特に要求される部分に限定して使用するのが効果的です。
  • 任意精度ではない: double-doubleは、f64 の約2倍の精度を提供する固定精度の数値型です。さらに高い精度(例えば100桁など)が必要な場合は、rugmalachite といった任意精度演算ライブラリを検討する必要があります。

計算物理学において、ほとんどの場面では f64 で十分です。しかし、その限界と、xprec のような高精度計算の選択肢があることを知っておくことは、より信頼性の高いシミュレーションを行う上で非常に重要です。

Last change: , commit: 23d4376