diff --git a/python/causal_impact/analysis.py b/python/causal_impact/analysis.py index 9ce9364..525ac50 100644 --- a/python/causal_impact/analysis.py +++ b/python/causal_impact/analysis.py @@ -11,21 +11,34 @@ class CausalImpactResults: """Results of causal impact analysis.""" - point_effects: np.ndarray # (T_post,) mean effect per time point - point_effect_lower: np.ndarray # (T_post,) lower CI per time point - point_effect_upper: np.ndarray # (T_post,) upper CI per time point - ci_lower: float # lower CI bound on average effect - ci_upper: float # upper CI bound on average effect - point_effect_mean: float # mean of point effects across time - cumulative_effect: np.ndarray # (T_post,) cumulative point effects - cumulative_effect_lower: np.ndarray # (T_post,) lower cumulative CI - cumulative_effect_upper: np.ndarray # (T_post,) upper cumulative CI - cumulative_effect_total: float # total cumulative effect - relative_effect_mean: float # relative effect (effect / predicted) - p_value: float # Bayesian one-sided tail probability - predictions_mean: np.ndarray # (T_post,) mean counterfactual - predictions_lower: np.ndarray # (T_post,) lower CI counterfactual - predictions_upper: np.ndarray # (T_post,) upper CI counterfactual + actual: np.ndarray + point_effects: np.ndarray + point_effect_lower: np.ndarray + point_effect_upper: np.ndarray + ci_lower: float + ci_upper: float + point_effect_mean: float + average_effect_sd: float + cumulative_effect: np.ndarray + cumulative_effect_lower: np.ndarray + cumulative_effect_upper: np.ndarray + cumulative_effect_total: float + cumulative_effect_sd: float + relative_effect_mean: float + relative_effect_sd: float + relative_effect_lower: float + relative_effect_upper: float + p_value: float + predictions_mean: np.ndarray + predictions_sd: np.ndarray + predictions_lower: np.ndarray + predictions_upper: np.ndarray + average_prediction_sd: float + average_prediction_lower: float + average_prediction_upper: float + cumulative_prediction_sd: float + cumulative_prediction_lower: float + cumulative_prediction_upper: float class CausalAnalysis: @@ -42,17 +55,10 @@ def compute_effects( n_samples = predictions.shape[0] - # Effect per sample per time point: observed - counterfactual - # predictions shape: (n_samples, t_post) - effects = y_post[np.newaxis, :] - predictions # (n_samples, t_post) + effects = y_post[np.newaxis, :] - predictions + avg_effects = effects.mean(axis=1) + point_effects = effects.mean(axis=0) - # Average effect across time for each sample - avg_effects = effects.mean(axis=1) # (n_samples,) - - # Point effects: mean across samples at each time point - point_effects = effects.mean(axis=0) # (t_post,) - - # Summary-table CI on average effect uses sample-average quantiles. lower_q = alpha / 2 upper_q = 1 - alpha / 2 point_effect_lower = np.percentile(effects, 100 * lower_q, axis=0) @@ -60,10 +66,8 @@ def compute_effects( ci_lower = float(np.percentile(avg_effects, 100 * lower_q)) ci_upper = float(np.percentile(avg_effects, 100 * upper_q)) - # Mean effect point_effect_mean = float(avg_effects.mean()) - # Cumulative effect cumulative_effect = np.cumsum(point_effects) cum_effects_samples = np.cumsum(effects, axis=1) cumulative_effect_lower = np.percentile( @@ -78,40 +82,107 @@ def compute_effects( ) cumulative_effect_total = float(cumulative_effect[-1]) - # Relative effect - pred_mean_total = predictions.mean() - if abs(pred_mean_total) > 1e-10: - relative_effect_mean = point_effect_mean / pred_mean_total + actual = y_post.copy() + + if n_samples == 1: + predictions_sd_arr = np.zeros(predictions.shape[1]) + else: + predictions_sd_arr = np.std(predictions, axis=0, ddof=1) + + avg_pred_per_sample = predictions.mean(axis=1) + cum_pred_per_sample = predictions.sum(axis=1) + + if n_samples == 1: + average_prediction_sd = 0.0 + cumulative_prediction_sd = 0.0 else: - relative_effect_mean = 0.0 + average_prediction_sd = float(np.std(avg_pred_per_sample, ddof=1)) + cumulative_prediction_sd = float(np.std(cum_pred_per_sample, ddof=1)) + + average_prediction_lower = float( + np.percentile(avg_pred_per_sample, 100 * lower_q) + ) + average_prediction_upper = float( + np.percentile(avg_pred_per_sample, 100 * upper_q) + ) + cumulative_prediction_lower = float( + np.percentile(cum_pred_per_sample, 100 * lower_q) + ) + cumulative_prediction_upper = float( + np.percentile(cum_pred_per_sample, 100 * upper_q) + ) + + cum_effects_per_sample = effects.sum(axis=1) + + if n_samples == 1: + average_effect_sd = 0.0 + cumulative_effect_sd = 0.0 + else: + average_effect_sd = float(np.std(avg_effects, ddof=1)) + cumulative_effect_sd = float(np.std(cum_effects_per_sample, ddof=1)) + + avg_pred_per_sample_safe = np.where( + np.abs(avg_pred_per_sample) > 1e-10, + avg_pred_per_sample, + np.nan, + ) + rel_effects_per_sample = np.where( + np.abs(avg_pred_per_sample) > 1e-10, + avg_effects / avg_pred_per_sample_safe, + 0.0, + ) + + relative_effect_mean = float(rel_effects_per_sample.mean()) + + if n_samples == 1: + relative_effect_sd = 0.0 + else: + relative_effect_sd = float(np.std(rel_effects_per_sample, ddof=1)) + + relative_effect_lower = float( + np.percentile(rel_effects_per_sample, 100 * lower_q) + ) + relative_effect_upper = float( + np.percentile(rel_effects_per_sample, 100 * upper_q) + ) - # p-value: proportion of samples where average effect has opposite sign if point_effect_mean >= 0: p_value = float(np.mean(avg_effects < 0)) else: p_value = float(np.mean(avg_effects > 0)) - # Ensure minimum p-value of 1/n_samples p_value = max(p_value, 1.0 / n_samples) - # Counterfactual prediction summaries predictions_mean = predictions.mean(axis=0) predictions_lower = np.percentile(predictions, 100 * lower_q, axis=0) predictions_upper = np.percentile(predictions, 100 * upper_q, axis=0) return CausalImpactResults( + actual=actual, point_effects=point_effects, point_effect_lower=point_effect_lower, point_effect_upper=point_effect_upper, ci_lower=ci_lower, ci_upper=ci_upper, point_effect_mean=point_effect_mean, + average_effect_sd=average_effect_sd, cumulative_effect=cumulative_effect, cumulative_effect_lower=cumulative_effect_lower, cumulative_effect_upper=cumulative_effect_upper, cumulative_effect_total=cumulative_effect_total, + cumulative_effect_sd=cumulative_effect_sd, relative_effect_mean=relative_effect_mean, + relative_effect_sd=relative_effect_sd, + relative_effect_lower=relative_effect_lower, + relative_effect_upper=relative_effect_upper, p_value=p_value, predictions_mean=predictions_mean, + predictions_sd=predictions_sd_arr, predictions_lower=predictions_lower, predictions_upper=predictions_upper, + average_prediction_sd=average_prediction_sd, + average_prediction_lower=average_prediction_lower, + average_prediction_upper=average_prediction_upper, + cumulative_prediction_sd=cumulative_prediction_sd, + cumulative_prediction_lower=cumulative_prediction_lower, + cumulative_prediction_upper=cumulative_prediction_upper, ) diff --git a/python/causal_impact/summary.py b/python/causal_impact/summary.py index 2f0185f..eb61575 100644 --- a/python/causal_impact/summary.py +++ b/python/causal_impact/summary.py @@ -6,42 +6,83 @@ class SummaryFormatter: - """Format CausalImpact results as text summary or natural language report.""" + """Format CausalImpact results as text summary or report.""" @staticmethod def summary(results: CausalImpactResults, digits: int = 2) -> str: fmt = f".{digits}f" - avg_effect = format(results.point_effect_mean, fmt) - avg_ci = f"[{format(results.ci_lower, fmt)}, {format(results.ci_upper, fmt)}]" - cum_effect = format(results.cumulative_effect_total, fmt) - cum_ci = ( + avg_actual = format(results.actual.mean(), fmt) + cum_actual = format(results.actual.sum(), fmt) + + avg_pred = format(results.predictions_mean.mean(), fmt) + avg_pred_sd = format(results.average_prediction_sd, fmt) + cum_pred = format(results.predictions_mean.sum(), fmt) + cum_pred_sd = format(results.cumulative_prediction_sd, fmt) + + avg_pred_ci = ( + f"[{format(results.average_prediction_lower, fmt)}, " + f"{format(results.average_prediction_upper, fmt)}]" + ) + cum_pred_ci = ( + f"[{format(results.cumulative_prediction_lower, fmt)}, " + f"{format(results.cumulative_prediction_upper, fmt)}]" + ) + + avg_eff = format(results.point_effect_mean, fmt) + avg_eff_sd = format(results.average_effect_sd, fmt) + cum_eff = format(results.cumulative_effect_total, fmt) + cum_eff_sd = format(results.cumulative_effect_sd, fmt) + + avg_eff_ci = ( + f"[{format(results.ci_lower, fmt)}, {format(results.ci_upper, fmt)}]" + ) + cum_eff_ci = ( f"[{format(results.cumulative_effect_lower[-1], fmt)}, " f"{format(results.cumulative_effect_upper[-1], fmt)}]" ) - rel_effect = format(results.relative_effect_mean * 100, fmt) + + rel_m = format(results.relative_effect_mean * 100, fmt) + rel_sd = format(results.relative_effect_sd * 100, fmt) + rel_lo = format(results.relative_effect_lower * 100, fmt) + rel_hi = format(results.relative_effect_upper * 100, fmt) + p_val = format(results.p_value, f".{max(digits, 3)}f") + prob = format((1 - results.p_value) * 100, fmt) + + pred_row = ( + f"Prediction (s.d.) " + f"{avg_pred} ({avg_pred_sd}) " + f"{cum_pred} ({cum_pred_sd})" + ) + eff_row = ( + f"Absolute effect (s.d.) " + f"{avg_eff} ({avg_eff_sd}) " + f"{cum_eff} ({cum_eff_sd})" + ) + rel_row = f"Relative effect (s.d.) {rel_m}% ({rel_sd}%) {rel_m}% ({rel_sd}%)" + rel_ci_row = ( + f"95% CI [{rel_lo}%, {rel_hi}%] [{rel_lo}%, {rel_hi}%]" + ) lines = [ "Posterior inference {CausalImpact}", "", " Average Cumulative", - "Actual - -", - "Prediction (s.d.) - -", - f"95% CI {avg_ci} {cum_ci}", + f"Actual {avg_actual} {cum_actual}", + pred_row, + f"95% CI {avg_pred_ci} {cum_pred_ci}", + "", + eff_row, + f"95% CI {avg_eff_ci} {cum_eff_ci}", "", - f"Absolute effect (mean) {avg_effect} {cum_effect}", - f"Relative effect {rel_effect}%", + rel_row, + rel_ci_row, "", f"Posterior tail-area probability p: {p_val}", + f"Posterior prob. of a causal effect: {prob}%", ] - if results.p_value < 0.05: - lines.append("Posterior prob. of a causal effect: " - f"{format((1 - results.p_value) * 100, fmt)}%") - else: - lines.append("The effect is not statistically significant.") - return "\n".join(lines) @staticmethod diff --git a/tests/test_analysis.py b/tests/test_analysis.py index 120432a..ec8af58 100644 --- a/tests/test_analysis.py +++ b/tests/test_analysis.py @@ -5,18 +5,13 @@ def _make_predictions(n_samples, t_post, base, effect=0.0, noise_sd=0.1, seed=42): - """Helper: create synthetic predictions (counterfactual) and observed y_post.""" rng = np.random.default_rng(seed) - # Counterfactual predictions (no effect) predictions = base + rng.normal(0, noise_sd, (n_samples, t_post)) - # Observed = counterfactual + true effect y_post = base + effect + rng.normal(0, noise_sd, t_post) return predictions, y_post class TestEffectDetection: - """効果検出テスト.""" - def test_positive_effect_detected(self): predictions, y_post = _make_predictions( 500, 20, base=10.0, effect=3.0, noise_sd=0.3 @@ -26,7 +21,7 @@ def test_positive_effect_detected(self): ) assert isinstance(result, CausalImpactResults) assert result.point_effect_mean > 0 - assert result.ci_lower > 0 # significant positive effect + assert result.ci_lower > 0 def test_negative_effect_detected(self): predictions, y_post = _make_predictions( @@ -36,7 +31,7 @@ def test_negative_effect_detected(self): y_post=y_post, predictions=predictions, alpha=0.05 ) assert result.point_effect_mean < 0 - assert result.ci_upper < 0 # significant negative effect + assert result.ci_upper < 0 def test_no_effect_zero(self): predictions, y_post = _make_predictions( @@ -45,15 +40,11 @@ def test_no_effect_zero(self): result = CausalAnalysis.compute_effects( y_post=y_post, predictions=predictions, alpha=0.05 ) - # CI should contain zero assert result.ci_lower <= 0 <= result.ci_upper class TestCIAndPValue: - """信頼区間とp値のテスト.""" - def test_ci_coverage_95(self): - """95% CIが真の効果値を含むことを検証.""" true_effect = 2.0 predictions, y_post = _make_predictions( 1000, 30, base=10.0, effect=true_effect, noise_sd=0.3 @@ -83,10 +74,7 @@ def test_p_value_not_significant(self): class TestCumulativeAndRelative: - """累積効果と相対効果.""""" - def test_cumulative_effect_monotone(self): - """正の効果のみの場合、累積効果は単調増加.""" predictions, y_post = _make_predictions( 500, 20, base=10.0, effect=5.0, noise_sd=0.1 ) @@ -95,7 +83,7 @@ def test_cumulative_effect_monotone(self): ) cum = result.cumulative_effect for i in range(1, len(cum)): - assert cum[i] >= cum[i - 1] - 1e-10 # allow tiny float errors + assert cum[i] >= cum[i - 1] - 1e-10 def test_relative_effect_percentage(self): predictions, y_post = _make_predictions( @@ -104,15 +92,57 @@ def test_relative_effect_percentage(self): result = CausalAnalysis.compute_effects( y_post=y_post, predictions=predictions, alpha=0.05 ) - # Relative effect ≈ 2.0/10.0 = 20% assert abs(result.relative_effect_mean - 0.2) < 0.1 + def test_summary_stats_use_posterior_sample_aggregates(self): + y_post = np.array([10.0, 10.0]) + predictions = np.array( + [ + [8.0, 8.0], + [10.0, 6.0], + [11.0, 7.0], + ] + ) -class TestPointwiseCI: - """各時点CI(R実装一致)テスト.""" + result = CausalAnalysis.compute_effects( + y_post=y_post, + predictions=predictions, + alpha=0.0, + ) + assert np.array_equal(result.actual, y_post) + assert np.allclose(result.predictions_sd, np.std(predictions, axis=0, ddof=1)) + assert result.average_prediction_sd == np.sqrt(1.0 / 3.0) + assert result.average_prediction_lower == 8.0 + assert result.average_prediction_upper == 9.0 + assert result.cumulative_prediction_sd == np.sqrt(4.0 / 3.0) + assert result.cumulative_prediction_lower == 16.0 + assert result.cumulative_prediction_upper == 18.0 + assert result.average_effect_sd == np.sqrt(1.0 / 3.0) + assert result.cumulative_effect_sd == np.sqrt(4.0 / 3.0) + assert result.relative_effect_lower == 1.0 / 9.0 + assert result.relative_effect_upper == 0.25 + + def test_single_sample_degenerates_sd_to_zero(self): + y_post = np.array([10.0, 12.0, 14.0]) + predictions = np.array([[9.0, 11.0, 13.0]]) + + result = CausalAnalysis.compute_effects( + y_post=y_post, + predictions=predictions, + alpha=0.05, + ) + + assert np.array_equal(result.predictions_sd, np.zeros(3)) + assert result.average_prediction_sd == 0.0 + assert result.cumulative_prediction_sd == 0.0 + assert result.average_effect_sd == 0.0 + assert result.cumulative_effect_sd == 0.0 + assert result.relative_effect_sd == 0.0 + + +class TestPointwiseCI: def test_pointwise_ci_bounds_shape(self): - """point_effect_lower/upper の形状は (t_post,).""" predictions, y_post = _make_predictions( 200, 15, base=10.0, effect=2.0, noise_sd=0.3 ) @@ -121,7 +151,6 @@ def test_pointwise_ci_bounds_shape(self): assert result.point_effect_upper.shape == (15,) def test_pointwise_ci_lower_le_mean_le_upper(self): - """各時点で lower <= point_effects <= upper が成立.""" predictions, y_post = _make_predictions( 200, 20, base=10.0, effect=2.0, noise_sd=0.3 ) @@ -130,15 +159,13 @@ def test_pointwise_ci_lower_le_mean_le_upper(self): assert np.all(result.point_effect_upper >= result.point_effects - 1e-10) def test_pointwise_lower_le_upper(self): - """各時点で lower <= upper.""" predictions, y_post = _make_predictions( 200, 20, base=10.0, effect=0.0, noise_sd=0.5 ) result = CausalAnalysis.compute_effects(y_post=y_post, predictions=predictions) assert np.all(result.point_effect_lower <= result.point_effect_upper + 1e-10) - def test_summary_ci_uses_average_effect_quantiles_for_r_summary_table(self): - """R summary の平均CIは pointwise CI 平均ではなく sample 平均の分位点を使う.""" + def test_summary_ci_uses_average_effect_quantiles(self): predictions, y_post = _make_predictions( 300, 20, base=10.0, effect=3.0, noise_sd=0.3 ) @@ -147,8 +174,7 @@ def test_summary_ci_uses_average_effect_quantiles_for_r_summary_table(self): assert result.ci_lower == float(np.percentile(sample_average_effects, 2.5)) assert result.ci_upper == float(np.percentile(sample_average_effects, 97.5)) - def test_alpha_01_is_wider_than_alpha_05_because_tail_mass_is_smaller(self): - """alpha=0.01のCIはalpha=0.05より広い.""" + def test_alpha_01_wider_than_alpha_05(self): predictions, y_post = _make_predictions( 500, 20, base=10.0, effect=2.0, noise_sd=0.3 ) @@ -166,8 +192,7 @@ def test_alpha_01_is_wider_than_alpha_05_because_tail_mass_is_smaller(self): width_01 = r01.ci_upper - r01.ci_lower assert width_01 > width_05 - def test_pointwise_quantile_average_differs_from_average_quantile(self): - """pointwise CI 平均と summary CI は別定義なので一致しないことがある.""" + def test_pointwise_quantile_avg_differs_from_avg_quantile(self): y_post = np.array([0.0, 0.0]) predictions = np.array( [ @@ -192,10 +217,9 @@ def test_pointwise_quantile_average_differs_from_average_quantile(self): assert float(result.point_effect_lower.mean()) == -4.0 assert float(result.point_effect_lower.mean()) != legacy_ci_lower - def test_pointwise_and_summary_ci_diverge_exactly_in_two_sample_symmetric_case( + def test_pointwise_and_summary_ci_diverge_in_symmetric_case( self, ): - """対称な2サンプル2時点では pointwise CI と summary CI の差を手計算できる.""" y_post = np.array([10.0, 10.0]) predictions = np.array( [ @@ -218,10 +242,7 @@ def test_pointwise_and_summary_ci_diverge_exactly_in_two_sample_symmetric_case( class TestCumulativeCI: - """累積効果CIテスト.""" - def test_cumulative_ci_shape(self): - """cumulative_effect_lower/upper の形状は (t_post,).""" predictions, y_post = _make_predictions( 200, 15, base=10.0, effect=2.0, noise_sd=0.3 ) @@ -230,7 +251,6 @@ def test_cumulative_ci_shape(self): assert result.cumulative_effect_upper.shape == (15,) def test_cumulative_ci_lower_le_mean_le_upper(self): - """累積効果の各時点で lower <= cumulative_effect <= upper.""" predictions, y_post = _make_predictions( 200, 20, base=10.0, effect=2.0, noise_sd=0.3 ) @@ -243,7 +263,6 @@ def test_cumulative_ci_lower_le_mean_le_upper(self): ) def test_cumulative_lower_le_upper(self): - """累積効果CIで lower <= upper.""" predictions, y_post = _make_predictions( 200, 20, base=10.0, effect=0.0, noise_sd=0.5 ) @@ -253,17 +272,14 @@ def test_cumulative_lower_le_upper(self): ) def test_cumulative_ci_widens_over_time(self): - """正の効果では累積CI幅は時間と共に広がる(単調増加の傾向).""" predictions, y_post = _make_predictions( 1000, 20, base=10.0, effect=0.0, noise_sd=1.0, seed=0 ) result = CausalAnalysis.compute_effects(y_post=y_post, predictions=predictions) widths = result.cumulative_effect_upper - result.cumulative_effect_lower - # 最終幅 > 最初の幅(累積誤差が積み上がるため) assert widths[-1] > widths[0] - def test_cumulative_ci_uses_quantiles_of_cumulative_sample_paths(self): - """累積CIは sample ごとの cumsum を作ってから各時点で分位点を取る.""" + def test_cumulative_ci_uses_quantiles_of_cumsum_paths(self): y_post = np.array([10.0, 10.0]) predictions = np.array( [ @@ -284,8 +300,6 @@ def test_cumulative_ci_uses_quantiles_of_cumulative_sample_paths(self): class TestBoundary: - """境界値テスト.""" - def test_single_post_point(self): predictions, y_post = _make_predictions( 500, 1, base=10.0, effect=1.0, noise_sd=0.3 @@ -299,11 +313,9 @@ def test_single_post_point(self): assert len(result.point_effect_upper) == 1 assert len(result.cumulative_effect_lower) == 1 assert len(result.cumulative_effect_upper) == 1 - # 1時点の場合: ci == pointwise == cumulative の最終値 assert abs(result.ci_lower - float(result.point_effect_lower[0])) < 1e-10 def test_two_samples_minimum(self): - """n_samples=2 の境界値: 分位点は2点の端値.""" t_post = 5 rng = np.random.default_rng(0) predictions = rng.normal(10.0, 0.5, (2, t_post)) @@ -311,12 +323,10 @@ def test_two_samples_minimum(self): result = CausalAnalysis.compute_effects( y_post=y_post, predictions=predictions, alpha=0.05 ) - # CI は存在する assert result.ci_lower < result.ci_upper assert result.point_effect_lower.shape == (t_post,) def test_single_sample_collapses_all_ci_widths_to_zero(self): - """サンプルが1本しかないと全CIは退化して平均値そのものになる.""" y_post = np.array([10.0, 12.0, 14.0]) predictions = np.array([[9.0, 11.0, 13.0]]) @@ -341,8 +351,7 @@ def test_single_sample_collapses_all_ci_widths_to_zero(self): assert result.ci_lower == result.ci_upper == 1.0 assert result.p_value == 1.0 - def test_identical_samples_make_ci_width_zero_because_every_path_matches(self): - """全サンプル同値なら分位点に幅は生じない.""" + def test_identical_samples_make_ci_width_zero(self): y_post = np.array([10.0, 12.0, 14.0]) predictions = np.tile(np.array([[9.0, 11.0, 13.0]]), (4, 1)) @@ -359,8 +368,7 @@ def test_identical_samples_make_ci_width_zero_because_every_path_matches(self): ) assert result.ci_lower == result.ci_upper == 1.0 - def test_alpha_zero_uses_full_range_for_pointwise_and_summary_quantiles(self): - """alpha=0 では pointwise は各時点の端値、summary は sample 平均の端値を返す.""" + def test_alpha_zero_uses_full_range(self): y_post = np.array([10.0, 10.0]) predictions = np.array( [ @@ -382,8 +390,7 @@ def test_alpha_zero_uses_full_range_for_pointwise_and_summary_quantiles(self): assert result.ci_lower == -0.5 assert result.ci_upper == 0.5 - def test_alpha_one_collapses_to_median_for_pointwise_and_summary_quantiles(self): - """alpha=1 では lower=upper=中央値になりCI幅がゼロになる.""" + def test_alpha_one_collapses_to_median(self): y_post = np.array([10.0, 10.0]) predictions = np.array( [ diff --git a/tests/test_plot.py b/tests/test_plot.py index 072153c..125d6a6 100644 --- a/tests/test_plot.py +++ b/tests/test_plot.py @@ -6,7 +6,6 @@ def _make_results_with_index(): - """Create results and time index for plotting.""" import pandas as pd t_pre = 70 @@ -16,28 +15,39 @@ def _make_results_with_index(): y = np.random.default_rng(42).normal(10, 1, t_total) time_index = pd.date_range("2020-01-01", periods=t_total, freq="D") results = CausalImpactResults( + actual=np.full(t_post, 12.0), point_effects=np.full(t_post, 2.0), point_effect_lower=np.full(t_post, 1.5), point_effect_upper=np.full(t_post, 2.5), ci_lower=1.0, ci_upper=3.0, point_effect_mean=2.0, + average_effect_sd=0.2, cumulative_effect=np.cumsum(np.full(t_post, 2.0)), cumulative_effect_lower=np.cumsum(np.full(t_post, 1.5)), cumulative_effect_upper=np.cumsum(np.full(t_post, 2.5)), cumulative_effect_total=60.0, + cumulative_effect_sd=6.0, relative_effect_mean=0.2, + relative_effect_sd=0.02, + relative_effect_lower=0.1, + relative_effect_upper=0.3, p_value=0.01, predictions_mean=np.full(t_post, 10.0), + predictions_sd=np.full(t_post, 0.5), predictions_lower=np.full(t_post, 9.0), predictions_upper=np.full(t_post, 11.0), + average_prediction_sd=0.5, + average_prediction_lower=9.0, + average_prediction_upper=11.0, + cumulative_prediction_sd=15.0, + cumulative_prediction_lower=270.0, + cumulative_prediction_upper=330.0, ) return results, y, time_index, t_pre class TestPlot: - """Plotのテスト.""" - def test_plot_3_panels(self): import matplotlib @@ -62,11 +72,9 @@ def test_plot_vertical_line_at_intervention(self): matplotlib.use("Agg") results, y, time_index, t_pre = _make_results_with_index() fig = Plotter.plot(results, y, time_index, t_pre) - # Check that vertical lines exist in each subplot axes = fig.get_axes() for ax in axes: lines = ax.get_lines() - # At least the intervention vertical line should be present assert len(lines) >= 1 def test_plot_pointwise_panel_draws_ci_band(self): diff --git a/tests/test_summary.py b/tests/test_summary.py index e0c28b8..61a8e14 100644 --- a/tests/test_summary.py +++ b/tests/test_summary.py @@ -6,30 +6,40 @@ def _make_results(effect=2.0, p_value=0.01): - """Create a CausalImpactResults fixture.""" t_post = 10 return CausalImpactResults( + actual=np.full(t_post, 12.0), point_effects=np.full(t_post, effect), point_effect_lower=np.full(t_post, effect * 0.75), point_effect_upper=np.full(t_post, effect * 1.25), ci_lower=effect * 0.5, ci_upper=effect * 1.5, point_effect_mean=effect, + average_effect_sd=effect * 0.1, cumulative_effect=np.cumsum(np.full(t_post, effect)), cumulative_effect_lower=np.cumsum(np.full(t_post, effect * 0.75)), cumulative_effect_upper=np.cumsum(np.full(t_post, effect * 1.25)), cumulative_effect_total=effect * t_post, + cumulative_effect_sd=effect, relative_effect_mean=effect / 10.0, + relative_effect_sd=effect / 100.0, + relative_effect_lower=effect / 20.0, + relative_effect_upper=effect / 5.0, p_value=p_value, predictions_mean=np.full(t_post, 10.0), + predictions_sd=np.full(t_post, 0.5), predictions_lower=np.full(t_post, 9.0), predictions_upper=np.full(t_post, 11.0), + average_prediction_sd=0.5, + average_prediction_lower=9.0, + average_prediction_upper=11.0, + cumulative_prediction_sd=5.0, + cumulative_prediction_lower=90.0, + cumulative_prediction_upper=110.0, ) class TestSummaryFormat: - """summary出力フォーマット.""" - def test_summary_default_format(self): result = _make_results(effect=2.0, p_value=0.01) text = SummaryFormatter.summary(result, digits=2) @@ -37,35 +47,45 @@ def test_summary_default_format(self): assert "Cumulative" in text assert "2.0" in text or "2.00" in text + def test_summary_includes_r_style_sections(self): + result = _make_results(effect=2.0, p_value=0.01) + + text = SummaryFormatter.summary(result, digits=2) + lines = text.split("\n") + + assert "Actual 12.00 120.00" in lines + assert "Prediction (s.d.) 10.00 (0.50) 100.00 (5.00)" in lines + assert "95% CI [9.00, 11.00] [90.00, 110.00]" in lines + assert "Absolute effect (s.d.) 2.00 (0.20) 20.00 (2.00)" in lines + assert "95% CI [1.00, 3.00] [15.00, 25.00]" in lines + assert "Relative effect (s.d.) 20.00% (2.00%) 20.00% (2.00%)" in lines + assert "95% CI [10.00%, 40.00%] [10.00%, 40.00%]" in lines + def test_summary_report_format(self): result = _make_results(effect=2.0, p_value=0.01) text = SummaryFormatter.report(result) - # Report should be natural language assert len(text) > 100 assert "effect" in text.lower() or "impact" in text.lower() def test_summary_digits_0(self): result = _make_results(effect=2.345, p_value=0.01) text = SummaryFormatter.summary(result, digits=0) - assert "2.345" not in text # should be rounded + assert "2.345" not in text def test_summary_digits_10(self): result = _make_results(effect=2.0, p_value=0.01) text = SummaryFormatter.summary(result, digits=10) assert isinstance(text, str) - def test_summary_shows_cumulative_ci_in_95_percent_ci_row(self): - """95% CI 行の cumulative 列には最終時点の累積CIを表示する.""" + def test_summary_shows_cumulative_ci_in_effect_row(self): result = _make_results(effect=2.0, p_value=0.01) text = SummaryFormatter.summary(result, digits=2) - ci_line = next(line for line in text.split("\n") if "95% CI" in line) + ci_line = text.split("\n")[8] assert "15.00" in ci_line assert "25.00" in ci_line class TestReportContent: - """レポート内容の検証.""" - def test_report_significant_effect(self): result = _make_results(effect=3.0, p_value=0.001) text = SummaryFormatter.report(result)