データサイエンティストのための確率的挙動を含むソフトウェアテスト
はじめに
最近 Zenn に記事を書くことをライフワークにしたいと思っている redtea です。本業として所属する組織が変わりましたので、当面の間は個人で Zenn 記事を書いていく予定です。
機械学習や数理最適化の実務を担当していると、そのソフトウェアに含まれる確率的挙動によりデバッグやテストに苦労することがあるかもしれません。多くの場合でシード値[1]を固定することでデバッグやテストができますが、それだけでは不十分なこともあります。
本記事では、確率的挙動を含むソフトウェア、特に機械学習・数理最適化分野のシステムに対するテスト戦略に焦点を当て、私なりの考え方と具体的な実践方法を紹介します。なお、今回は焦点にあてていませんが、外部APIを用いる場合に同じ入力でも若干異なる出力が得られる場合もあります。そのような場合でも、本記事の内容はテストやデバッグ時の一助になるはずです。
テストにおける課題
言わずもがなかもしれませんが、確率的挙動を持つソフトウェアのテストには主に以下の課題があります。
- 再現性の欠如: 最大の課題です。テストを実行するたびに結果が変わる可能性があるため、「期待される結果」を定義し、それと一致するかどうかを単純に比較することが難しくなります。テストが失敗した際に、それが本当にバグなのか、それとも単なる確率的な揺らぎなのかを判断するのが困難になります。
- デバッグの困難さ: テストが失敗しても、同じ条件で再現させることが難しいと、原因究明(デバッグ)が非常に困難になります。特定の乱数系列でしか発生しないバグを見つけ、修正するのは骨の折れる作業です。
- 網羅性の担保の難しさ: 確率的な挙動は、理論的には無限に近い実行パスを生み出す可能性があります。すべての可能性をテストすることは不可能であり、どの程度のテストを行えば「十分」と言えるのか、その基準を設定するのが難しいという問題があります。
本記事では、これらの課題に対処するアプローチを紹介します。もちろん、この記事の内容だけでは万全ではございませんので、ご指摘やアイデアなどございましたら教えてください。
本記事の想定
ソフトウェアの品質
この記事で扱う品質とは、機械学習アルゴリズムや数理最適化の精度の高さではなく、ソフトウェアが実装者の意図通りに動作しているかに焦点を当てます。
想定読者
- Python等を用いて機械学習や数理最適化を活用しているエンジニア
- ソフトウェア品質を高めることに関心のあるエンジニア
したがって、ある程度機械学習や数理最適化に通じている実践したい人を想定していますので、
- 何故確率的な要素が含まれるのか
- 乱数シードの仕組み
などは説明しません(付録には一部含まれています)。
乱数そのものに関する解説は、以下の記事が詳しいです。
本記事のアウトライン
確率的挙動を含むソフトウェアのテスト戦略には、大きく分けて3つあると考えています[2]。
- 乱数を制御して、決定論的にテストする方法(乱数制御アプローチ)
- 統計的なアサーション[3]によって確率的にテストする方法(統計的アプローチ)
- ソフトウェアが満たすべき性質をテストする方法(性質的アプローチ)
1は乱数シード値を固定する等、最もイメージしやすく、実践的な方法かと思います。が、単に乱数シード値を固定するだけではなく、テストしやすい設計にする話や、具体的なテストコードの実装にも踏み込みながら解説していきます。
2, 3は乱数シード値を固定せずに正しさを確認する方法です。デバッグなどには向きませんが、1だけでは発見が困難なバグ検出に役立ちます。2と3の違いは統計的性質を用いるか用いないかの違いですが、この違いはテストを書く際に大きな影響があるので章分けしました。
本記事ではこれら1つ1つに対して章を設け解説していきます。記事を締めくくった後、付録として細かな自分用のメモをダラダラと[4]実装上役に立つ事柄も書いています(折りたたんでいます)。
1.乱数制御アプローチ
1.1.シード値を固定する
ソフトウェア開発において、「再現性」は非常に重要です。同じ入力に対しては常に同じ出力が得られる(決定論的である)ことは、バグの特定、デバッグ、そして品質保証の基盤です。
まずは、テストの再現性を確保するための基本戦略である「シード値固定」について掘り下げていきましょう。
基本的な考え方とメリット・デメリット
このアプローチの核心は、テストコードの実行前に、関連するすべての乱数生成器のシード値を特定の値に設定することです。これにより、テスト対象コード内で乱数が使われていても、毎回同じ順序で同じ値が生成され、結果としてテスト全体の挙動が決定論的になります。
メリット:
- 再現性: テストが常に同じ結果を返すため、成功・失敗が明確になります。
- デバッグ容易性: テストが失敗した場合、同じシード値で再実行すれば現象を再現できるため、原因究明が格段に容易になります。
- CI/CD[5]との親和性: テスト結果が安定するため、自動テストパイプラインに組み込みやすいです。リグレッション[6]の検出にも有効です。
デメリット:
- 特定の乱数系列への依存: テストがパスしたとしても、それはたまたまそのシード値(乱数系列)で問題が起きなかっただけかもしれません。他のシード値では失敗する隠れたバグを見逃す可能性があります。
- 網羅性の限界: 確率的な挙動によって起こりうる多様なケースの一部しかカバーできません。アルゴリズムの頑健性(様々な状況下での安定性)を評価するには不十分な場合があります。
これらのデメリットは試す固定シード値を複数用意することである程度緩和されるかもしれませんが、当然完璧にテストすることは不可能です[7]。後述する、2.統計的アプローチや3.性質的アプローチと併用することで、より頑健なテストになります。
【実践】Pythonでのシード値固定
シード値の固定位置
実際にシード値を固定する際には、各テストケース(テスト関数)の実行直前に固定するのが最も安全と考えています。これにより、他のテストケースの影響を受けずに、常に同じ初期状態からテストを開始できます。以下、それぞれの粒度でシード値を固定した場合どうなるかを整理したものです。
- テストスイート全体: テスト実行の開始時に一度だけ固定します。すべてのテストケースで同じ乱数系列が(順番に)使われます。テスト間の独立性が損なわれる可能性があるため、注意が必要です。
- テストクラスごと: テストクラスのセットアップメソッド(例:
unittest.TestCase
のsetUpClass
やpytest
のクラススコープのfixture)で固定します。クラス内のテストケースでは共通の初期状態から始まります。 -
テスト関数ごと: 各テスト関数の実行前に固定します(例:
unittest.TestCase
のsetUp
やpytest
の関数スコープのfixture)。テストケース間の独立性を保つのに適しています。 - 特定の処理ブロック: テスト関数内の一部分だけで再現性が必要な場合に、
with
文などを使って局所的に固定することもできますが、これを用いるのは特殊な状況と思われます。
サンプルコード
pytest
を使ったテスト関数ごとのシード値固定の例を示します。既存のコードにテストを追加する場合、以下のようになると思います。
import pytest
import random
import numpy as np
import torch
# import tensorflow as tf # 必要に応じて
SEED = 42
@pytest.fixture(autouse=True, scope="function") # 各テスト関数の実行前に自動で呼ばれるfixture
def set_seed():
"""テスト実行前に各種ライブラリのシード値を固定する"""
random.seed(SEED)
np.random.seed(SEED)
# NumPyの新方式を使う場合 (推奨)
# global_rng = np.random.default_rng(SEED) # グローバルに使う場合。通常は関数内で生成・利用が良い
torch.manual_seed(SEED)
if torch.cuda.is_available():
torch.cuda.manual_seed_all(SEED) # GPUも忘れずに
# tf.random.set_seed(SEED) # TensorFlowを使う場合
# オプション: 再現性を高めるための設定 (フレームワークによる)
torch.backends.cudnn.deterministic = True
torch.backends.cudnn.benchmark = False
# os.environ['TF_DETERMINISTIC_OPS'] = '1' # TensorFlowの場合
# --- テスト対象のサンプル関数 ---
def my_random_process():
"""乱数を使って何か処理する関数"""
val1 = random.choice([1, 2, 3])
val2 = np.random.rand()
val3 = torch.rand(1).item()
return val1 + val2 + val3
def test_my_random_process_fixed_seed():
"""シード値固定下でのテスト"""
result1 = my_random_process()
# auto fixture を用意しているので、同じ条件でテストが可能
# set_seed() # 必要なら再度呼ぶ
result2 = my_random_process()
# シード値固定により、毎回同じ結果が得られるはず
expected_result1 = 1 + 0.3745... + 0.8339... # SEED=42の場合の期待値 (事前に計算 or 実行して確認)
expected_result2 = 3 + 0.9507... + 0.0583... # SEED=42で2回目の呼び出しの期待値
print(f"Result 1: {result1}, Result 2: {result2}")
# 浮動小数点数の比較には注意が必要
assert abs(result1 - expected_result1) < 1e-6
assert abs(result2 - expected_result2) < 1e-6
# 別のテスト関数
def test_another_process():
# set_seed fixture により、このテストもSEED=42の初期状態で始まる
# (my_random_processとは独立した状態)
assert random.randint(0, 100) == 81 # SEED=42の場合の最初のrandint(0, 100)の結果
上記のようなpytest
fixture の実装により、シード値固定のロジックを共通化でき、テストコードがすっきりします。autouse=True
で自動適用、スコープ(scope='function'
など)を指定して適用範囲を制御できます。
ただしこの例では、random.seed()
や np.random.seed()
を用いており、グローバルな状態へ依存しています[8]。理想的には NumPy の np.random.default_rng()
や random.Random()
のように、インスタンスごとに状態を持つ独立した乱数生成器を使うことを推奨します。これにより、テスト間の独立性が高まり、意図しない副作用を防げます。詳しくはスタブを活用する節で扱います。
1.2.複数のシード値でテストする
シード値を固定すれば再現性は得られますが、そのシード値だけで安心するのは危険です。たまたまそのシード値(特定の乱数系列)ではうまくいくけれど、他のシード値では問題が発生するというケースは十分にありえます。
例えば、
- 特定の乱数系列ではあるソフトウェアのある分岐を辿らない
- 特定の乱数系列でのみ発生する数値計算上のエラー(オーバーフロー、ゼロ除算など)。
このような「シード値依存のバグ」や「不安定な挙動」を見つけるためには、複数の異なるシード値でテストを実行することが重要になります。
【実践】複数シード値でのテスト実装
pytest
の parametrize
を使うと、同じテスト関数を異なるパラメータ(この場合はシード値)で繰り返し実行できます。
import pytest
import random
import numpy as np
# テストするシード値のリスト
SEEDS_TO_TEST = [0, 42, 123, 999, 20250428]
@pytest.mark.parametrize("seed", SEEDS_TO_TEST)
def test_potentially_unstable_function_multiple_seeds(seed):
"""複数のシード値でテストを実行"""
print(f"\nTesting with seed: {seed}") # どのシード値で実行中か表示
try:
result = calc_hoge(seed)
expected = ...
print(f"Result: {result}")
# ここでは、結果が通常の範囲内にあることを確認する
# (不安定な挙動が起きないことを期待する場合)
assert result == expected, f"Result {result} is out of expected range with seed {seed}"
except Exception as e:
pytest.fail(f"Function failed with seed {seed}: {e}")
# 実行すると、各シード値でテストが独立して実行される
# もし SEED=123 で assert が失敗した場合、出力は以下のようになる(イメージ)
# FAILED tests/test_random.py::test_potentially_unstable_function_multiple_seeds[123] - AssertionError: Result 100 is out of expected range with seed 123
このテストが失敗した場合、出力にどのシード値で失敗したかが含まれるため、そのシード値を使ってローカル環境で再現し、デバッグを進めることができます。
失敗時のシード値の記録と再現方法:
- CI環境でテストを実行する場合、失敗したテストで使用されたシード値をログに出力するようにしておくことが重要です。
-
pytest
の-s
オプションを使うとprint
文の出力が表示されるため、デバッグに役立ちます。 - 失敗時のログに
Testing with seed: 123
のような情報があれば、ローカルでpytest test_my_module.py -k test_potentially_unstable_function_multiple_seeds[123]
のように特定のパラメータのテストだけを実行して再現できます。
1.3.スタブを活用する
スタブとは、ある関数をテストする際、その関数が呼び出すものの代わりに用いる代用のことです。例えば、A
クラスがB
クラスを呼び出す設計で、A
とB
とが同時に開発中に、B
のスタブを作ることでA
はB
の完成を待たずに開発を進められます。この時、B
のスタブは特定の値を返すだけの張りぼてのようなイメージです。これを乱数を作る部分に活用します。
ソフトウェア全体のテスト容易性を高めるために非常に重要なのが、コードの設計段階で、決定論的な部分と確率的な部分を意識的に分離することです。
- 決定論的な部分: アルゴリズムの中心となる計算や、状態遷移のロジックなど、乱数に依存しない部分は、従来の単体テストや結合テストで検証します。シード値固定の必要もなく、テストは安定し、デバッグも容易です。
- 確率的な部分: 乱数生成や、それを利用するコンポーネント(初期値生成、サンプリング、確率的選択など)は、可能な限り小さな単位に分離します。
この分離を実現する有効な方法の1つが、依存性の注入(Dependency Injection, DI) パターンです。乱数生成器自体を、それを使用するクラスや関数に外部から注入(引数として渡すなど)できるように設計します。
【実践】インターフェースを通じた依存性の注入(DI)による分離例
import numpy as np
from abc import ABC, abstractmethod
# --- 乱数生成のインターフェース (抽象基底クラス) ---
class RandomNumberGenerator(ABC):
@abstractmethod
def generate(self, size):
pass
# --- 実際の乱数生成器 (NumPyを使用) ---
class NumpyRNG(RandomNumberGenerator):
def __init__(self, seed=None):
self._rng = np.random.default_rng(seed)
def generate(self, size):
return self._rng.random(size)
# --- 乱数を使用するクラス (例: 何かの初期化処理) ---
class MyInitializer:
def __init__(self, rng: RandomNumberGenerator): # 外部からRNGを受け取る
self._rng = rng
def initialize_weights(self, shape):
# 注入されたRNGを使って処理を行う
raw_values = self._rng.generate(shape)
# 何か決定論的な処理... (例: スケーリング)
weights = raw_values * 0.01
return weights
# --- テストコード ---
# モック or スタブを使用
class MockRNG(RandomNumberGenerator): # テスト用の偽のRNG
def generate(self, size):
# 常に固定値を返すようにする
return np.full(size, 0.5)
def test_initializer_deterministic_part():
mock_rng = MockRNG()
initializer = MyInitializer(mock_rng)
weights = initializer.initialize_weights((2, 3))
# 決定論的な処理 (スケーリング) が正しく行われたか確認
expected = np.full((2, 3), 0.5 * 0.01)
np.testing.assert_allclose(weights, expected)
def test_initializer_with_real_rng_fixed_seed():
# シード値を固定したテストも可能
real_rng = NumpyRNG(seed=42)
initializer = MyInitializer(real_rng)
weights = initializer.initialize_weights((2, 3))
expected = np.array([[0.0077, 0.0043, 0.0085], [0.0059, 0.0015, 0.0007]]) # 事前計算値
np.testing.assert_allclose(weights, expected, atol=1e-4)
このように設計することで、スタブを使った決定論的なテスト、シード値を固定した再現性テスト、を使い分けることが容易になります。次章の統計的アプローチでは、シード値を固定しませんが、スタブで疑似乱数を返すようにすればその切り替えも容易になります。
2.統計的アプローチ
シード値を固定(あるいは複数試す)して、期待通りの「特定の値」になるかを確認するだけでは、確率的アルゴリズムのテストとしては不十分な場合があります。本章で扱うテストは、単なる「この入力なら、この出力」というテストではなく、「このアルゴリズムは、確率的なゆらぎはあっても、全体としてこういう性質を持っているはずだ」という仮説を検証するテストです。
このアプローチではシード値を固定せず、テスト実行ごとに異なる乱数列を生成させます。その代わり、テストのアサーション(検証内容)を工夫します。
メリット:
- 現実的な評価: 実行ごとにシード値が変わる本番環境に近い状況でテストでき、より現実的な条件下での性能や挙動を評価できます。
- 予期せぬ挙動の発見可能性: シード値固定テストでは見逃していた、特定の(あるいは予期しない)乱数系列で発生する問題や、アルゴリズムの不安定性を発見できる可能性があります。
- 頑健性の評価: 本記事の主題からは外れますが、アルゴリズムが様々な初期値やランダムな摂動に対して、どれだけ安定した結果を出せるかを直接的に評価できます。
デメリット:
- 再現性の低さ: テストが失敗した場合、同じ状況を再現するのが困難なため、デバッグが格段に難しくなります。
- 結果のばらつきとテストの不安定性: 確率的なゆらぎによって、本来バグではないのにテストが時々失敗する(Flaky Test)可能性があります。これを避けるためには、アサーションの閾値調整などが難しくなります。
2.1.結果が期待に近しいことを確認する
【実践】例1(最適化アルゴリズム)
ある最適化アルゴリズムが、様々な初期値(シード値)からスタートしても、高い確率で目標とする性能値(例:コスト関数の値が特定以下)に到達することを保証したい。
import pytest
import numpy as np
def my_optimizer(initial_solution, seed, target_cost):
"""最適化アルゴリズムのシミュレーション"""
rng = np.random.default_rng(seed)
current_solution = initial_solution
current_cost = calculate_cost(current_solution) # 仮のコスト計算関数
steps = 0
max_steps = 100
while current_cost > target_cost and steps < max_steps:
# 確率的な探索ステップ (例: ランダムな近傍解を生成)
neighbor_solution = current_solution + rng.normal(0, 0.1, size=current_solution.shape)
neighbor_cost = calculate_cost(neighbor_solution)
if neighbor_cost < current_cost: # 簡単な山登り法的な更新
current_solution = neighbor_solution
current_cost = neighbor_cost
steps += 1
# 実際にはもっと洗練されたアルゴリズム (SA, GAなど)
return current_cost <= target_cost # 目標コストを達成できたか (True/False)
def calculate_cost(solution):
"""コストを計算するダミー関数"""
return np.sum(solution**2)
NUM_TRIALS = 50 # 試行回数
TARGET_COST = 0.1
REQUIRED_SUCCESS_RATE = 0.8 # 80%以上の確率で成功してほしい
def test_optimizer_success_rate():
"""複数回の試行で、目標達成率が期待値以上かテスト"""
initial_solution = np.array([1.0, -1.0, 0.5])
success_count = 0
seeds = range(NUM_TRIALS) # 0 から NUM_TRIALS-1 までのシード値
for seed in seeds:
if my_optimizer(initial_solution.copy(), seed, TARGET_COST):
success_count += 1
success_rate = success_count / NUM_TRIALS
print(f"Success rate over {NUM_TRIALS} trials: {success_rate:.2f}")
# 成功率が要求水準を満たしているかアサート
assert success_rate >= REQUIRED_SUCCESS_RATE, \
f"Success rate {success_rate:.2f} is below required {REQUIRED_SUCCESS_RATE}"
# より厳密には、二項検定などで統計的に評価することも可能
# from scipy.stats import binom_test
# p_value = binom_test(success_count, n=NUM_TRIALS, p=REQUIRED_SUCCESS_RATE, alternative='greater')
# assert p_value > 0.05 # 例えば、有意水準5%で「成功率が要求水準より低いとは言えない」ことを確認
【実践】例2(サンプリング関数)
特定の確率分布(例:正規分布)からサンプリングを行う関数が、期待される分布に従った値を生成しているかを確認したい。
import pytest
import numpy as np
from scipy import stats
def generate_normal_samples(n_samples, mu, sigma, seed):
"""正規分布に従うサンプルを生成する関数"""
rng = np.random.default_rng(seed)
return rng.normal(loc=mu, scale=sigma, size=n_samples)
N_SAMPLES = 1000
MU = 5.0
SIGMA = 2.0
SEED = 42
def test_sample_distribution():
"""生成されたサンプルが期待される正規分布に従うか検定"""
samples = generate_normal_samples(N_SAMPLES, MU, SIGMA, SEED)
# 1. 平均値と標準偏差が期待値に近いか確認 (簡単なチェック)
assert abs(np.mean(samples) - MU) < 0.5 # 許容誤差はサンプル数や期待値による
assert abs(np.std(samples) - SIGMA) < 0.5
# 2. 統計的検定 (より厳密なチェック)
# コルモゴロフ・スミルノフ検定 (KS検定) を使用
# 帰無仮説 H0: サンプルは指定された分布 (正規分布) に従う
# 対立仮説 H1: サンプルは指定された分布に従わない
ks_statistic, p_value = stats.kstest(samples, 'norm', args=(MU, SIGMA))
print(f"KS test p-value: {p_value}")
# p値が有意水準 (例: 0.05) より大きい場合、帰無仮説を棄却できない
# つまり、「サンプルが正規分布に従わないとは言えない」と判断できる
assert p_value > 0.05, "KS test failed: Sample distribution differs significantly from normal distribution."
# 他にもシャピロ・ウィルク検定 (正規性検定) など、目的に応じて検定手法を選択
# shapiro_test = stats.shapiro(samples)
# assert shapiro_test.pvalue > 0.05
このような十分性を確認するテストは、単なる値の一致を見るよりも高度ですが、確率的アルゴリズムが本質的に持つべき性質を保証する上で非常に重要です。
2.2.統計的な性質を検証する
複数回実行した結果を集計し、その統計的な性質(平均、分散、中央値、成功率、信頼区間など)が期待される範囲内に収まっているかを検証します。
【実践】テストコードでの統計的評価例
前の test_optimizer_success_rate
の例は、まさにこの統計的評価の一種です。ここでは、別の例として、ある関数の出力の平均値と分散を評価するテストを考えます。
import numpy as np
import pytest
def noisy_calculation(base_value, noise_level, rng_instance):
"""ベース値に正規分布ノイズを加える関数"""
noise = rng_instance.normal(loc=0, scale=noise_level)
return base_value + noise
NUM_RUNS = 100
BASE_VALUE = 10.0
NOISE_LEVEL = 0.5 # ノイズの標準偏差
EXPECTED_MEAN = BASE_VALUE # 期待される平均値
EXPECTED_MAX_STD_DEV = NOISE_LEVEL * 1.2 # 期待される標準偏差の上限 (理論値より少し余裕を持たせる)
def test_noisy_calculation_statistics():
"""複数回実行し、結果の平均と標準偏差を評価"""
results = []
seeds = range(NUM_RUNS)
for seed in seeds:
rng = np.random.default_rng(seed)
result = noisy_calculation(BASE_VALUE, NOISE_LEVEL, rng)
results.append(result)
mean_result = np.mean(results)
std_dev_result = np.std(results)
print(f"\nResults over {NUM_RUNS} runs:")
print(f" Mean: {mean_result:.4f} (Expected: {EXPECTED_MEAN:.4f})")
print(f" Std Dev: {std_dev_result:.4f} (Expected max: {EXPECTED_MAX_STD_DEV:.4f})")
# 平均値が期待値に十分近いか (許容誤差を設定)
assert abs(mean_result - EXPECTED_MEAN) < 0.2, "Mean value is too far from expected."
# 標準偏差が期待される上限を超えていないか
assert std_dev_result < EXPECTED_MAX_STD_DEV, "Standard deviation is larger than expected."
# 注意: 試行回数 (NUM_RUNS) が少ないと、統計的な推定誤差が大きくなり、
# テストが不安定になる可能性があります。適切な試行回数と許容誤差の設定が必要です。
この方法は、アルゴリズムの平均的な性能や結果のばらつきを評価するのに有効です。例えば、まとまったデータセットに対して機械学習モデルの推論を行い、出力の分布(平均値や標準偏差)を評価したり、A/Bテストのように複数のアルゴリズムを同じ条件(異なるシード値)で複数回実行し、性能指標の分布を比較したりするのに応用できます。
3.性質的アプローチ
目的は2章の統計的アプローチと似ていますが、アプローチが統計的ではないことが特徴です。この記事ではさらに、メタモルフィックアプローチとプロパティチェックアプローチに分類します。例えば、「sin関数に
テスト対象が矛盾した振る舞いをした場合にのみバグを検知できるので、検知できる範囲はかなり限定的です。しかし、1, 2章で紹介したアプローチとは観点が異なるので、他の方法で検知できないかもしれないものを検知できるポテンシャルがあります。また、シード値を固定しないので、アルゴリズムが変化してもこのテスト関数は書き換える必要がないことも利点です。
3.1.メタモルフィックアプローチ
Metamorphic Testing のことです。「メタモルフィック」という言葉が聞きなれない方もいらっしゃるかと思います。メタモルフィック(metamorphic)には、「変形の」、「変態の」といった意味があり、正しい出力結果がわからなくとも、条件を変形したときにどう出力が変化するかを頼りにします。例えば、ある確率的挙動を含むアルゴリズム 2f(x) == f(2x)
というアサーションが書けます。この時の入力値を2倍にすれば出力も2倍になるという関係性をメタモルフィック関係と呼びます。
メタモルフィックテスティングの考え方。入力
例えばある乱数が用いられるアルゴリズムに以下のような関係があれば、それはメタモルフィックな関係です。なお、以下に挙げた例は私が思いついたもの + ChatGPTが考えたものです。
関係 (例) | 変換操作 | 期待される性質 P |
---|---|---|
スケール不変 | 入力を +k 倍 (k>0) | 出力は k 倍 |
項目順序非依存 | アイテムをランダムに並べ替え | 選択されるアイテムの集合は同一 |
加算平行移動不変 (Additive Consistency) | すべての入力に定数 c を足す | 出力も +c だけ平行移動 |
冪等性 (Idempotence) | 出力を再び関数に入力 | 出力は変わらない |
重複アイテム無視 (Duplicate-Invariance) | 重複する要素を加える | 出力は変わらない |
アフィン不変 (Affine Combination) | スケール変化と平行移動を組み合わせる | 出力は変わらない |
メタモルフィックテスティングのサンプルコード
# tests/test_metamorphic.py
import random
from typing import Sequence
import numpy as np
import pytest
# テスト対象 -----------------------------------------------------------
from mymodule import target_func # ここを自分の関数に置き換え
# 共通設定 -------------------------------------------------------------
NUM_TRIALS = 10 # 各メタモルフィック関係を試す回数
RTOL = 1e-6 # 浮動小数の許容誤差
# 入力生成関数例 -------------------------------------------------------
def rand_vector(min_len=5, max_len=30, low=-1e3, high=1e3) -> np.ndarray:
"""実数ベクトルを乱数生成"""
n = np.random.randint(min_len, max_len + 1)
return np.random.uniform(low, high, n)
def rand_int_list(min_len=10, max_len=40, low=0, high=1_000) -> list[int]:
"""整数リスト(重複なし)を乱数生成"""
n = np.random.randint(min_len, max_len + 1)
return random.sample(range(low, high), k=n) # sample で重複回避
# 1. スケール不変 -------------------------------------------------------
@pytest.mark.parametrize("k", [0.1, 0.5, 2.0, 10.0])
def test_scale_invariance(k: float):
for _ in range(NUM_TRIALS):
x = rand_vector()
y1 = target_func(x)
y2 = target_func(x * k)
assert np.allclose(y2, y1 * k, rtol=RTOL)
# 2. 項目順序非依存 ----------------------------------------------------
def test_order_independence():
for _ in range(NUM_TRIALS):
data = rand_int_list()
out1 = set(target_func(data))
random.shuffle(data) # 順序だけ変える
out2 = set(target_func(data))
assert out1 == out2
# 3. 加算平行移動不変 --------------------------------------------------
@pytest.mark.parametrize("c", [-100, -1, 0.5, 123.45])
def test_additive_consistency(c: float):
for _ in range(NUM_TRIALS):
x = rand_vector()
y1 = target_func(x)
y2 = target_func(x + c)
assert np.allclose(y2, y1 + c, rtol=RTOL)
# 4. 冪等性 ------------------------------------------------------------
def test_idempotence():
for _ in range(NUM_TRIALS):
data = rand_int_list()
out1 = target_func(data)
out2 = target_func(out1)
assert out1 == out2
# 5. 重複アイテム無視 (Duplicate-Invariance) -----------------------
def test_duplicate_invariance():
"""
入力に同じ要素を複製しても、選択結果の集合が変わらないことを期待。
重複が意味を持たないアルゴリズムで有効。
"""
for _ in range(NUM_TRIALS):
base = rand_int_list()
dup = base + random.choices(base, k=len(base)//2) # 一部を重複させる
out1 = set(target_func(base))
out2 = set(target_func(dup))
assert out1 == out2
# 6. アフィン不変 (Affine Combination) ----------------------------
@pytest.mark.parametrize("k,c", [(2.0, 10.0), (0.5, -5.0)])
def test_affine_invariance(k: float, c: float):
"""
スケール + 平行移動の合成線形変換に対する不変性。
"""
for _ in range(NUM_TRIALS):
x = rand_vector()
y1 = target_func(x)
y2 = target_func(x * k + c)
assert np.allclose(y2, y1 * k + c, rtol=RTOL)
3.2.プロパティチェックアプローチ
不変条件(Invariant)に基づくテストと言った方が伝わりやすい方もいらっしゃるでしょう。確率的な挙動に関係なく、プロパティが常に満たされるべき性質(不変条件) をテストする方法です。シード値を固定する必要はなく、決定論的なテストと同じようにアサートできます。もちろんこちらのテストも、ソフトウェアの最低限の挙動を確認しているに過ぎないので、補助的なもの、致命的なバグを検知するもの、といった立ち位置で考えています。
不変条件の例:
- 出力値の範囲: 関数の戻り値が特定の範囲(例:0から1の間、正の値)に収まっているべき。
- 物理法則や数学的な制約: シミュレーション結果がエネルギー保存則を満たす、確率の合計が1になる、など。
- リソース消費量: 関数の実行時間やメモリ使用量が一定の上限を超えない。
- 状態の整合性: オブジェクトの内部状態が、操作後も矛盾なく保たれている。
- リストの要素数: ある操作を行ってもリストの要素数が変わらない、あるいは期待通りに増減する。
import pytest
import random
def generate_probabilities(n_classes, seed=None):
"""クラス数n_classesの確率リストを生成する (バグあり)"""
if seed is not None:
random.seed(seed)
# 本来は合計が1になるように正規化すべきだが、単純にランダム値を生成しているのでバグ
probs = [random.random() for _ in range(n_classes)]
# バグ: 合計が1にならない可能性がある & 負の値がないとは限らない
# total = sum(probs)
# probs = [p / total for p in probs]
return probs
def test_generate_probabilities_invariants():
"""生成された確率リストが満たすべき不変条件をテスト"""
n_classes = 5
# シード値は固定しない (してもよいが、不変条件なので必須ではない)
probabilities = generate_probabilities(n_classes)
print(f"\nGenerated probabilities: {probabilities}")
# 不変条件1: リストの長さは n_classes と等しいはず
assert len(probabilities) == n_classes
# 不変条件2: 各要素は 0 以上のはず (今回は random.random() なので満たされる)
for p in probabilities:
assert p >= 0.0
# 不変条件3: 各要素は 1 以下のはず (今回は random.random() なので満たされる)
for p in probabilities:
assert p <= 1.0
# 不変条件4: 合計が 1.0 になるはず (許容誤差 epsilon を考慮)
# このテストは、現在の generate_probabilities 実装では失敗する!
epsilon = 1e-6
# assert abs(sum(probabilities) - 1.0) < epsilon, f"Sum of probabilities {sum(probabilities)} is not 1.0"
# --> AssertionError: Sum of probabilities 2.18... is not 1.0
このテストは、確率的な詳細に立ち入ることなく、関数の基本的な契約(仕様)が守られているかを確認するのに役立ちます。
プロパティベーステストの活用
不変条件テストの考え方をさらに推し進めたのが、プロパティベーステスト(Property-Based Testing, PBT) です。PBTでは、具体的な入力値と期待出力のペアをテストケースとして記述する代わりに、「どのような入力が与えられても、満たされるべき性質(プロパティ)」を定義します。テストフレームワーク(Pythonでは Hypothesis
が有名)が、そのプロパティを満たすかどうかを、ランダムに生成された多様な入力データを使って自動的に検証してくれます。
PBTは、確率的挙動を含むソフトウェアのテストと非常に相性が良いです。なぜなら、テストフレームワークがシード値を含む様々なランダムな入力を試してくれるため、開発者が想定していなかったエッジケースや、特定の乱数系列で問題を引き起こすようなバグを発見できる可能性が高いからです。
import pytest
from hypothesis import given, strategies as st, settings, HealthCheck
import numpy as np
# テスト対象: 簡単なクラスタリング関数 (K-means風)
def simple_kmeans(data, k, seed):
rng = np.random.default_rng(seed)
n_samples = data.shape[0]
n_features = data.shape[1]
# 1. ランダムに初期中心点を選択
initial_indices = rng.choice(n_samples, k, replace=False)
centers = data[initial_indices]
# (簡単のため、重心計算と割り当ての反復は省略)
# 2. 各データ点を最も近い中心点に割り当てる
distances = np.sqrt(((data[:, np.newaxis, :] - centers[np.newaxis, :, :]) ** 2).sum(axis=2))
labels = np.argmin(distances, axis=1)
# 異常なケースをシミュレート (特定のシード値で空のクラスタができるなど)
if seed == 10 and k > 1:
# わざと一つのラベルしか返さないバグ
labels = np.zeros(n_samples, dtype=int)
return labels, centers
# プロパティベーステスト
# Hypothesisに生成してほしいデータの戦略を定義
data_strategy = st.lists(st.floats(min_value=-100, max_value=100), min_size=2, max_size=100) \
.map(lambda x: np.array(x).reshape(-1, 2)) # 2次元データ点のリストをNumPy配列に変換
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None) # fixture利用時の警告抑制とタイムアウト無効化
@given(
data=st.lists(st.tuples(st.floats(-10, 10), st.floats(-10, 10)), min_size=5, max_size=50).map(np.array), # 2次元データ (5-50点)
k=st.integers(min_value=1, max_value=5), # クラスタ数 (1-5)
seed=st.integers(min_value=0, max_value=10000) # シード値 (0-10000)
)
def test_kmeans_properties(data, k, seed):
"""K-means関数のプロパティをテスト"""
if k > data.shape[0]: # kがデータ点数より多い場合はスキップ
return
labels, centers = simple_kmeans(data, k, seed)
# プロパティ1: ラベル配列の長さはデータ点数と一致するはず
assert labels.shape[0] == data.shape[0], f"Seed: {seed}"
# プロパティ2: ラベルの値は 0 から k-1 の範囲にあるはず
assert np.all(labels >= 0) and np.all(labels < k), f"Seed: {seed}"
# プロパティ3: 中心点の数は k と一致するはず
assert centers.shape[0] == k, f"Seed: {seed}"
# プロパティ4: 中心点の次元数はデータの次元数と一致するはず
assert centers.shape[1] == data.shape[1], f"Seed: {seed}"
# プロパティ5: (もしk>1なら) 少なくとも2種類以上のラベルが存在するはず (空クラスタがないことの簡易チェック)
if k > 1 and data.shape[0] > k : # データ点がkより多い場合
unique_labels = np.unique(labels)
# このアサーションは、seed=10 の時に導入したバグにより失敗する
assert len(unique_labels) == k , \
f"Expected {k} unique labels, but got {len(unique_labels)} with seed {seed}. Labels: {labels}"
# --> Falsifying example: data=..., k=2, seed=10 で失敗する
# Hypothesisは、プロパティを破る入力 (data, k, seed の組み合わせ) を見つけようと試みる
# もし見つかれば、その最小限の反例を報告してくれる
Hypothesis
は、定義されたプロパティを破るような入力値(data
, k
, seed
の組み合わせ)を探索します。もし反例が見つかれば、テストは失敗し、その反例が報告されるため、デバッグの手がかりとなります。
2章、3章で紹介したアプローチは、設計の工夫や統計的な考え方を必要としますが、ソフトウェアの頑健性を高め、シード値固定だけでは見つけられないバグを発見するために有効な手段です。
結び
本記事では、3つのアプローチで確率的挙動を含むソフトウェアのテスト方法について考えてきました。それらのうちどれかを選ぶというわけではなく、状況に応じて組み合わせることで多角的に検証することが重要です。最後に整理します。
アプローチ | メリット | デメリット | 主な目的・適した場面 |
---|---|---|---|
乱数制御アプローチ | 再現性確保、デバッグ容易、CI/CDとの親和性 | 特定の乱数系列への依存、網羅性の限界 | バグ検出、リグレッションテスト、基本的な挙動確認 |
統計的アプローチ | 現実的な挙動評価、予期せぬ挙動の発見可能性 | 細部の再現性低、デバッグ困難、テスト不安定 | 性能評価(頑健性)、統計的な性質の確認、探索的テスト |
性質的アプローチ | テストコードの保守性、致命的バグの検知 | 検知できるバグが限定的 | 多角的なテスト、探索的テスト |
乱数制御アプローチは、従来の決定論的なソフトウェアテストに近い感覚でテストを記述・実行できるため、基本的なバグ検出やリグレッションテストの実装には非常に有効です。CI/CDパイプラインに組み込む際も、結果が安定するため扱いやすいです。しかし、たまたま選んだシード値では問題が顕在化しない可能性があり、テストの網羅性には限界があります。また、アルゴリズムを改善する度にテスト結果を作り直す必要があります。
統計的アプローチは、ソフトウェアが様々な状況下でどのように振る舞うか、その頑健性を評価するのに適しています。実行ごとに結果が異なることを前提とし、統計的な評価や不変条件のチェックを行います。予期せぬエッジケースを発見できる可能性もありますが、テストの再現性が低いため、失敗時の原因究明が難しく、テスト自体が不安定になる(Flaky Test)リスクも伴います。
性質的アプローチは、どんな状況でもソフトウェアが満たすべき条件を満たしているかを確認します。検知できるバグの範囲は限定的ですが、アルゴリズムの精度改善などに取り組んだ後も継続的に使えることや、致命的なバグの検知に有効です。さらには満たすべき条件がテストケースから明らかになるので、ドキュメント的な効果も発揮します。
これらはソフトウェア開発のフェーズに応じて段階的に組み込むことを想定しています。単体テストや結合テストの初期段階、リグレッションテストでは、シード値を固定して基本的なロジックの正しさや変更による影響がないことを確認するのが手っ取り早く、アルゴリズムの実装に専念できます。そして、アルゴリズムが固まってきて、より現実的な条件下での挙動を確認したいフェーズでは統計的アプローチや性質的アプローチを組み合わせることが有効になります。
最後に蛇足ですが、私が本記事より前に書いてきた記事は全て自らキーボードをタイプした文字だけで作ってきました(AIに出力させた文字列は記事中には一切ありませんでした)[9]。しかし今回からはサンプルコードと付録に限って、AIの出力を一部そのまま載せています。理由は、サンプルコードは「実装のたたき台」から、「あくまでも実装の雰囲気を伝えるもの」に変わったと感じているからです。実装のたたき台自体は、
pytest と Hypothesis を用いて、~を見たすプロパティベーステストを書いてください。
と生成AIのプロンプトに書けば得られます。また、その方がお手元の環境に沿ったたたき台が出てくるでしょう。サンプルコードはあくまでも実装の雰囲気を伝えるものとご認識ください。
参考文献
- 佐藤直人, 小川秀人, 來間 啓伸, 明神 智之, AIソフトウェアのテスト, リックテレコム, 2021.
- Brian Okken, テスト駆動Python 第2版, 翔泳社, 2022.
- Vladimir Khorikov, 単体テストの考え方/使い方, マイナビ出版, 2022.
- Chen, Tsong Yueh, et al., Metamorphic testing: A review of challenges and opportunities. ACM Computing Surveys, 2018.
付録
付録: 雑多なメモ(意外と長いです)
疑似乱数とは
ソフトウェアにおける確率的な挙動のほとんどは、「乱数」を使って実現されています。しかし、コンピュータは決定論的な機械であり、真の意味でのランダムさ(物理現象に基づくような)を作り出すのは簡単ではありません。そこで使われるのが**疑似乱数生成器(Pseudo Random Number Generator, PRNG)**です。
疑似乱数生成器(PRNG)の仕組み
PRNGは、ある初期値(シード値)から出発して、決定論的な計算アルゴリズムに基づいて、あたかもランダムに見える数列を生成する仕組みです。よく使われるアルゴリズムにはメルセンヌ・ツイスタなどがあります。
重要なのは、PRNGは決定論的であるという点です。つまり、同じシード値から始めれば、常に全く同じ疑似乱数列が生成されるということです。これは、一見ランダムに見える挙動に再現性を持たせるための鍵となります。
「サイコロ」に例えると、PRNGは「特定の目から始まって、決まった順番で目が出るイカサマサイコロ」のようなものです。どの目から振り始めるか(=シード値)を決めれば、次に出る目は常に同じになります。
シード値の役割
シード(Seed、種)値は、PRNGが疑似乱数列を生成し始めるための初期状態を決定する値です。通常は整数値が用いられます。
- 同じシード値を与えれば、PRNGは同じ数列を生成します。
- 異なるシード値を与えれば、PRNGは異なる数列を生成します(ただし、アルゴリズムによっては偶然同じ数列になる可能性もゼロではありません)。
- シード値を指定しない場合、多くのライブラリでは現在時刻やOSが提供するランダムな値などを利用して、実行ごとに異なるシード値が内部的に設定されるため、結果として実行ごとに異なる乱数列が生成されます。
この「同じシード値なら同じ数列」という性質を利用することで、確率的な挙動を含むソフトウェアのテストにおいて再現性を確保することが可能になります。
Pythonにおける主要な乱数生成ライブラリ
-
random
モジュール(標準ライブラリ):- Pythonに組み込まれている標準モジュールです。
- リストからのランダムな要素選択 (
random.choice()
) や、指定範囲の整数生成 (random.randint()
) など、基本的な乱数操作を提供します。 -
シード固定:
random.seed(a=None, version=2)
関数で行います。引数a
にシード値を指定します。 -
注意点:
random
モジュールはグローバルな状態を持ちます。つまり、プログラムのどこかでrandom.seed()
を呼ぶと、それ以降のすべてのrandom
モジュールの関数呼び出しに影響します。これは意図しない副作用を生む可能性があるため注意が必要です。
import random # シード値を 42 に固定 random.seed(42) print([random.random() for _ in range(3)]) # [0.6394..., 0.0250..., 0.2750...] # もう一度同じシード値で固定 random.seed(42) print([random.random() for _ in range(3)]) # [0.6394..., 0.0250..., 0.2750...] (同じ数列が生成される) # シード値を変える random.seed(123) print([random.random() for _ in range(3)]) # [0.2860..., 0.2219..., 0.5129...] (異なる数列が生成される)
-
numpy.random
モジュール (NumPy):- 数値計算ライブラリNumPyの一部で、効率的な配列操作と共に、多様な確率分布からのサンプリング機能を提供します (
numpy.random.randn()
,numpy.random.uniform()
など)。 -
シード値固定(旧方式):
numpy.random.seed(seed=None)
関数で行います。これもグローバルな状態に影響します。 -
シード値固定(新方式・推奨): NumPy 1.17以降では、
numpy.random.Generator
クラスを使う方法が推奨されています。numpy.random.default_rng(seed=None)
でジェネレータオブジェクトを作成し、そのオブジェクトのメソッド(例:rng.random()
,rng.integers()
)を使って乱数を生成します。この方法では、ジェネレータごとに独立した状態を持つため、グローバルな状態汚染を避けられます。
import numpy as np # --- 旧方式 (グローバル状態) --- np.random.seed(42) print(np.random.rand(3)) # [0.3745..., 0.9507..., 0.7319...] np.random.seed(42) print(np.random.rand(3)) # [0.3745..., 0.9507..., 0.7319...] (同じ) # --- 新方式 (推奨: Generatorオブジェクト) --- rng1 = np.random.default_rng(seed=42) print(rng1.random(3)) # [0.7739..., 0.4388..., 0.8585...] rng2 = np.random.default_rng(seed=42) print(rng2.random(3)) # [0.7739..., 0.4388..., 0.8585...] (同じシード値なら同じオブジェクトは同じ数列) rng3 = np.random.default_rng(seed=123) print(rng3.random(3)) # [0.6964..., 0.2861..., 0.2268...] (異なるシード値なら異なる数列) # rng1とrng3は独立している print(rng1.random(1)) # [0.5969...] (rng1の状態は進んでいる)
- 数値計算ライブラリNumPyの一部で、効率的な配列操作と共に、多様な確率分布からのサンプリング機能を提供します (
-
RandomState
オブジェクト (scikit-learnなど):- scikit-learnなどのライブラリでは、乱数生成の再現性を確保するために、関数の引数として
random_state
を受け取ることがよくあります。 -
random_state
には整数(シード値)またはnumpy.random.RandomState
(旧方式のジェネレータ) やnumpy.random.Generator
(新方式のジェネレータ) のインスタンスを渡すことができます。 - これにより、ライブラリの内部で使用される乱数生成を制御できます。
from sklearn.model_selection import train_test_split import numpy as np X = np.arange(10).reshape((5, 2)) y = range(5) # random_stateに整数 (シード値) を指定 X_train1, X_test1, y_train1, y_test1 = train_test_split(X, y, test_size=0.3, random_state=42) print("Seed 42:", X_test1) # [[4 5]] など (実行ごとに固定) # 再度同じシード値を指定 X_train2, X_test2, y_train2, y_test2 = train_test_split(X, y, test_size=0.3, random_state=42) print("Seed 42 again:", X_test2) # [[4 5]] など (同じ結果) # 異なるシード値を指定 X_train3, X_test3, y_train3, y_test3 = train_test_split(X, y, test_size=0.3, random_state=123) print("Seed 123:", X_test3) # [[2 3]] など (異なる結果) # Generatorオブジェクトを指定 (推奨) rng = np.random.default_rng(42) X_train4, X_test4, y_train4, y_test4 = train_test_split(X, y, test_size=0.3, random_state=rng) print("Generator(42):", X_test4) # [[4 5]] など (整数シード値と同じ結果になることが多いが、独立性を保てる)
- scikit-learnなどのライブラリでは、乱数生成の再現性を確保するために、関数の引数として
-
ディープラーニングフレームワーク (PyTorch, TensorFlow):
- PyTorchやTensorFlowなどのフレームワークも、パラメータ初期化、Dropout、データ拡張などで乱数を使用します。CPUだけでなくGPU上での乱数生成も制御する必要があります。
-
PyTorch:
torch.manual_seed(seed)
でCPUのシード値を、torch.cuda.manual_seed(seed)
やtorch.cuda.manual_seed_all(seed)
でGPUのシード値を固定します。 -
TensorFlow:
tf.random.set_seed(seed)
でグローバルなシード値を固定します。 - これらのフレームワークでは、データローダーのワーカプロセスなど、さらに注意すべき点があります(詳細は後述)。
# PyTorch の例 import torch torch.manual_seed(42) linear1 = torch.nn.Linear(10, 5) # パラメータ初期化に乱数が使われる print(linear1.weight[0, :3]) # tensor([ 0.0429, -0.0941, 0.1420]) torch.manual_seed(42) linear2 = torch.nn.Linear(10, 5) print(linear2.weight[0, :3]) # tensor([ 0.0429, -0.0941, 0.1420]) (同じ) # TensorFlow の例 import tensorflow as tf import os # 環境変数でも制御できる場合がある (後述の再現性の罠も参照) # os.environ['TF_DETERMINISTIC_OPS'] = '1' tf.random.set_seed(42) initializer = tf.keras.initializers.GlorotUniform() weights1 = initializer(shape=(10, 5)) print(weights1[0, :3]) # tf.Tensor([-0.0532 -0.3075 0.1162], shape=(3,), dtype=float32) tf.random.set_seed(42) initializer = tf.keras.initializers.GlorotUniform() weights2 = initializer(shape=(10, 5)) print(weights2[0, :3]) # tf.Tensor([-0.0532 -0.3075 0.1162], shape=(3,), dtype=float32) (同じ)
乱数を扱う上でのその他の注意点
グローバル状態 vs. オブジェクト状態、ライブラリ間の依存
乱数生成において特に注意すべきは、状態管理の方法です。
-
グローバル状態:
random.seed()
やnp.random.seed()
のように、一度設定するとプログラム全体の乱数生成に影響を与える方式です。手軽ですが、意図しない箇所で状態が変更されたり、他のテストケースに影響を与えたりするリスクがあります(テストの独立性が損なわれる)。 -
オブジェクト状態:
np.random.default_rng()
やnp.random.RandomState()
のように、乱数生成器のインスタンスを作成し、そのインスタンスが自身の状態を管理する方式です。インスタンスごとに独立した乱数列を生成できるため、状態管理がしやすく、副作用のリスクを低減できます。可能な限り、オブジェクト状態を用いる方式(特にNumPyのGenerator
)を利用することが推奨されます。
また、複数のライブラリ(例: random
と numpy
)を同時に使用する場合、それぞれのシード値を適切に管理する必要があります。random.seed()
は numpy.random
の挙動には影響しませんし、その逆も同様です。PyTorchやTensorFlowも独自の乱数生成器を持っているため、利用するすべてのライブラリに対してシード値固定を行う必要があります。
シード値の管理
-
テスト環境 vs. 本番環境:
- テスト環境: 再現性のため、通常はシード値を固定します(ただし、頑健性テストでは固定しない、または複数の値を試します)。CI環境で使うシード値は、毎回同じ値を使うか、あるいは実行ごとに記録されるランダムな値を使うか、戦略を決めておきましょう。
- 本番環境: 一般的には、シード値を固定すべきではありません。固定してしまうと、常に同じ乱数系列に基づいて動作することになり、現実世界の多様な状況に対応できなくなる可能性があります(例:常に同じ初期値から学習を開始する、常に同じデータ分割をするなど)。ただし、特定のユースケースで完全な再現性が求められる場合は、意図的に固定することもあります。本番環境でのシード値の扱い方は、プロダクトの要件に応じて慎重に決定してください。
- バージョン管理での記録: 再現性が重要なテストや実験で使用したシード値は、コードや設定ファイルと共にバージョン管理システム(Gitなど)で記録しておくことが推奨されます。これにより、過去の実行結果を後から再現・検証することが可能になります。
テスト実行時間とのトレードオフ
- 複数シード値テストや統計的評価のコスト: これらのテストは、通常の単体テストよりも実行に時間がかかります。特に、重い処理(モデル学習など)を何度も繰り返す場合、テスト全体の所要時間が大幅に増加する可能性があります。
-
CI戦略:
- すべてのプルリクエストで全シード値・全統計テストを実行するのは現実的でない場合があります。
- 対策として、プルリクエスト時には少数の代表的なシード値でテストし(基本的なリグレッションチェック)、夜間バッチなどで、より多くのシード値や時間のかかる統計テストを実行する、といった段階的な戦略が考えられます。
- テストの重要度に応じて、実行頻度や対象範囲を調整しましょう。
-
並列実行時の注意点: テスト時間を短縮するために
pytest-xdist
などを使ってテストを並列実行する場合、シード値の管理にはさらに注意が必要です。- グローバルなシード固定(
random.seed()
など)は、複数のプロセス/ワーカで意図しない影響を与え合う可能性があります。 - NumPyの
np.random.default_rng()
のような独立した乱数生成器を使うことがより重要になります。 - データローダーなどでマルチプロセスを使用する場合、ワーカプロセスごとに適切に異なるシードが設定されるように
worker_init_fn
などを工夫する必要があります(例: PyTorchのDataLoader)。 - 並列実行下での完全な再現性を確保するのは困難な場合があるため、トレードオフを理解した上で利用する必要があります。NumPy 1.21 以降の
SeedSequence
は、並列環境で独立した乱数ストリームを生成するのに役立ちます。
- グローバルなシード固定(
再現性の落とし穴
シード値をしっかり固定したつもりでも、必ずしも完全な再現性が得られないケースがあります。これらは「再現性の罠」とも言え、デバッグをさらに困難にします。
- マルチスレッド/マルチプロセス: 複数のスレッドやプロセスが共有の乱数生成器にアクセスする場合、実行タイミングによって乱数の消費順序が変わり、結果が変わる可能性があります。スレッド/プロセスごとに独立した乱数生成器を持たせるなどの対策が必要です。
-
GPUの非決定性演算: 特にディープラーニングにおいて、CUDA (cuDNN) の一部のアルゴリズムは、性能向上のために非決定的な動作をすることがあります(例: アトミック加算の順序)。これが結果のわずかな差異を生むことがあります。
-
対策: PyTorchでは
torch.backends.cudnn.deterministic = True
、torch.backends.cudnn.benchmark = False
、TensorFlowではtf.config.experimental.enable_op_determinism()
や環境変数TF_DETERMINISTIC_OPS=1
を設定することで、決定的な動作を強制できる場合がありますが、性能が低下する可能性があります。すべての演算が決定性を保証されるわけではない点にも注意が必要です。
-
対策: PyTorchでは
- 外部ライブラリの内部状態: 利用している外部ライブラリが内部で独自の乱数生成器や状態を持っている場合、そのライブラリのシード値も適切に管理しないと再現性が得られません。
- OSやハードウェアの違い: 稀なケースですが、OSやCPU/GPUのアーキテクチャの違いが浮動小数点演算のわずかな差異を生み、それが積み重なって結果に影響を与える可能性もゼロではありません。コンテナ技術(Dockerなど)を使って実行環境を固定化することが、これを軽減するのに役立ちます。
-
Pythonの辞書順序: Python は辞書や集合の要素の順序が固定されることは保証していません。これが間接的に乱数を使う処理(例: 辞書のキーをランダムに選ぶ)に影響を与える可能性があります。テスト環境で再現性を確保したい場合、環境変数
PYTHONHASHSEED
を固定値に設定する選択肢もありますが、通常は辞書の順序に依存しないコードを書くべきです。
フレームワーク固有の注意点
-
ドキュメントの確認: TensorFlow, PyTorch, scikit-learn, XGBoostなど、利用している機械学習/最適化フレームワークのドキュメントで、乱数管理や再現性に関するセクションを確認することが非常に重要です。多くの場合、再現性を確保するための推奨される設定方法や注意点が記載されています(例:
DataLoader
のworker_init_fn
でのシード値設定)。 - 状態を持つ乱数生成器: ライブラリによっては、内部で状態を持つ乱数生成器オブジェクトを使用している場合があります。これらのオブジェクトが意図せず共有されたり、リセットされなかったりすると、再現性の問題を引き起こす可能性があります。
-
乱数生成の初期値であり、同じシード値を使用すると同じ乱数の系列が生成されます。これを設定することで、確率的挙動を含むソフトウェアであっても繰り返し同じ結果を得ることができます。 ↩︎
-
以下の分類や命名は本記事執筆者が適当に決めたもので、学術的な背景などはございません。 ↩︎
-
pytest
などで一致確認する際、assert
文を用いることから、アサーション、アサートするといった表現を使っています。 ↩︎ -
Zenn の Scraps にでも書いてリンクでも貼れば良かったのかもしれませんが、同じ記事内の方が自分としても管理しやすいので、ここに雑に入れさせていただきました。 ↩︎
-
コードの統合・テスト・デプロイの一連の工程を自動化し、品質を保ちながら迅速にソフトウェアをリリースするための開発手法です。 ↩︎
-
ある変更によって意図しない挙動変化が起きていることを検知することを意味します。 ↩︎
-
そもそもソフトウェアテストというのはそういうものですが。 ↩︎
-
random.seed()
やnp.random.seed()
を用いると、他に乱数を使っている箇所にも影響がでます。一方で、np.random.default_rng()
やrandom.Random()
のように乱数生成器のインスタンスを使えば、対象の乱数生成のみのシード値を指定できます。 ↩︎ -
これは作業効率を落としていることになるかもしれませんが、自分が納得した文章で説明を書ききりたいという気持ちの問題です。記事を書く上で、AIの出力は適切に使えば十分すぎる性能だと思っています。 ↩︎
Discussion