mirror of
https://github.com/hanzoai/insights.git
synced 2026-10-30 16:15:31 +00:00
The tree was a debranded snapshot of upstream taken around 2026-02-20, and upstream has moved about five months since. This replaces it with upstream master, mechanically re-derived: bin/debrand does the rename, and the pipeline and squashed migrations are restored from the shipped tree. Not a merge. The previous import replaced the tree wholesale in a single commit, so there is no shared content history to merge against. Reproducing the rename on the new tree is what actually converges. What survives: .hanzo/workflows, the Dockerfiles and bin/docker* entrypoints, compose files, LLM.md, and insights/migrations. The squashed initial migration is load-bearing — the live database records it by name, so upstream's ~918 individual migrations cannot come back. Nine products are dropped as unreferenced. Twenty-one more that we do not run are kept anyway because the core app still imports them; unpicking those is a refactor and does not belong in a rebase. This base does not build yet, and the reasons are not in the rename: upstream now wants Python 3.13.13 against our 3.12.12 and Django 5.2 against our 4.2, and the dependency manifests still name upstream packages that we republish under our own names. Those are decisions, not renames.
356 lines
12 KiB
Python
356 lines
12 KiB
Python
"""
|
|
Bayesian test implementations for A/B testing.
|
|
|
|
This module provides Bayesian statistical test implementations that
|
|
output probabilistic results including chance to win, credible intervals,
|
|
and risk assessments.
|
|
"""
|
|
|
|
from abc import ABC, abstractmethod
|
|
from dataclasses import dataclass
|
|
|
|
import numpy as np
|
|
|
|
from ..shared.enums import DifferenceType
|
|
from ..shared.statistics import ProportionStatistic, RatioStatistic, SampleMeanStatistic, StatisticError
|
|
from .priors import GaussianPrior
|
|
from .utils import (
|
|
calculate_effect_size_and_variance,
|
|
calculate_posterior,
|
|
calculate_risk,
|
|
chance_to_win,
|
|
credible_interval,
|
|
validate_inputs,
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class BayesianResult:
|
|
"""
|
|
Result of a Bayesian statistical test.
|
|
|
|
Contains probabilistic outputs that are intuitive for business decision-making.
|
|
"""
|
|
|
|
# Point estimates
|
|
effect_size: float # Posterior mean of effect size
|
|
credible_interval: tuple[float, float] # Bayesian credible interval
|
|
|
|
# Probabilities
|
|
chance_to_win: float # P(treatment > control | data)
|
|
|
|
# Risk assessment
|
|
risk_control: float # Expected loss if we choose control
|
|
risk_treatment: float # Expected loss if we choose treatment
|
|
|
|
# Posterior distribution parameters
|
|
posterior_variance: float # Variance of posterior distribution
|
|
|
|
# Configuration
|
|
ci_level: float # Credible interval level (0.95 for 95% CI)
|
|
difference_type: str # Type of difference calculated
|
|
inverse: bool # Whether "lower is better"
|
|
|
|
# Prior information
|
|
prior_mean: float # Prior mean used
|
|
prior_variance: float # Prior variance used
|
|
proper_prior: bool # Whether informative prior was used
|
|
|
|
@property
|
|
def posterior_std(self) -> float:
|
|
"""Posterior standard deviation."""
|
|
return np.sqrt(self.posterior_variance)
|
|
|
|
@property
|
|
def is_decisive(self) -> bool:
|
|
"""Whether result shows clear preference (chance to win > ci_level or < 1 - ci_level)."""
|
|
return self.chance_to_win > self.ci_level or self.chance_to_win < 1 - self.ci_level
|
|
|
|
@property
|
|
def preferred_variation(self) -> str:
|
|
"""Which variation is preferred based on chance to win."""
|
|
if self.chance_to_win > 0.5:
|
|
return "treatment"
|
|
else:
|
|
return "control"
|
|
|
|
@property
|
|
def confidence_in_decision(self) -> float:
|
|
"""Confidence in the preferred variation."""
|
|
return max(self.chance_to_win, 1 - self.chance_to_win)
|
|
|
|
|
|
class BayesianTest(ABC):
|
|
"""Abstract base class for Bayesian statistical tests."""
|
|
|
|
def __init__(self, ci_level: float = 0.95, inverse: bool = False):
|
|
"""
|
|
Initialize Bayesian test.
|
|
|
|
Args:
|
|
ci_level: Credible interval level (default: 0.95 for 95% intervals)
|
|
inverse: Whether "lower is better" for this metric (default: False)
|
|
"""
|
|
if not (0 < ci_level < 1):
|
|
raise StatisticError("ci_level must be between 0 and 1")
|
|
|
|
self.ci_level = ci_level
|
|
self.inverse = inverse
|
|
|
|
@property
|
|
@abstractmethod
|
|
def test_name(self) -> str:
|
|
"""Return the test name identifier."""
|
|
pass
|
|
|
|
@abstractmethod
|
|
def run_test(
|
|
self,
|
|
treatment_stat: SampleMeanStatistic | ProportionStatistic | RatioStatistic,
|
|
control_stat: SampleMeanStatistic | ProportionStatistic | RatioStatistic,
|
|
prior: GaussianPrior,
|
|
difference_type: DifferenceType = DifferenceType.RELATIVE,
|
|
**kwargs,
|
|
) -> BayesianResult:
|
|
"""
|
|
Run the Bayesian statistical test.
|
|
|
|
Args:
|
|
treatment_stat: Treatment group statistic
|
|
control_stat: Control group statistic
|
|
prior: Prior distribution for effect size
|
|
difference_type: Type of difference to calculate
|
|
**kwargs: Additional test-specific parameters
|
|
|
|
Returns:
|
|
BayesianResult object
|
|
"""
|
|
pass
|
|
|
|
|
|
class BayesianGaussianTest(BayesianTest):
|
|
"""
|
|
Bayesian test using Gaussian conjugate priors for effect sizes.
|
|
|
|
Assumes the effect size follows a normal distribution and uses
|
|
Gaussian conjugate priors to compute posterior distributions.
|
|
|
|
Note: This is appropriate for large samples where the Central Limit
|
|
Theorem ensures approximate normality of effect size estimates.
|
|
"""
|
|
|
|
@property
|
|
def test_name(self) -> str:
|
|
return "bayesian_gaussian_test"
|
|
|
|
def run_test(
|
|
self,
|
|
treatment_stat: SampleMeanStatistic | ProportionStatistic | RatioStatistic,
|
|
control_stat: SampleMeanStatistic | ProportionStatistic | RatioStatistic,
|
|
prior: GaussianPrior,
|
|
difference_type: DifferenceType = DifferenceType.RELATIVE,
|
|
**kwargs,
|
|
) -> BayesianResult:
|
|
"""
|
|
Run Bayesian t-test comparing treatment vs control.
|
|
|
|
Args:
|
|
treatment_stat: Treatment group statistic
|
|
control_stat: Control group statistic
|
|
prior: Gaussian prior for effect size
|
|
difference_type: Type of difference to calculate (relative/absolute)
|
|
**kwargs: Additional parameters (currently unused)
|
|
|
|
Returns:
|
|
BayesianResult with all probabilistic outputs
|
|
|
|
Raises:
|
|
StatisticError: If inputs are invalid or calculations fail
|
|
"""
|
|
# Validate inputs
|
|
validate_inputs(treatment_stat, control_stat)
|
|
|
|
if not isinstance(prior, GaussianPrior):
|
|
raise StatisticError("Prior must be a GaussianPrior instance")
|
|
|
|
if difference_type not in [DifferenceType.RELATIVE, DifferenceType.ABSOLUTE]:
|
|
raise StatisticError("Only relative and absolute differences supported for Bayesian tests")
|
|
|
|
try:
|
|
# Calculate effect size and variance
|
|
unadjusted_mean = kwargs.get("unadjusted_mean")
|
|
effect_size, effect_variance = calculate_effect_size_and_variance(
|
|
treatment_stat, control_stat, difference_type, unadjusted_mean
|
|
)
|
|
|
|
# Bayesian posterior update
|
|
posterior_mean, posterior_variance = calculate_posterior(effect_size, effect_variance, prior)
|
|
|
|
posterior_std = np.sqrt(posterior_variance)
|
|
|
|
# Calculate probabilistic outputs
|
|
chance_win = chance_to_win(posterior_mean, posterior_std, self.inverse)
|
|
|
|
# Credible interval (convert ci_level to alpha for internal function)
|
|
alpha = 1 - self.ci_level
|
|
ci_lower, ci_upper = credible_interval(posterior_mean, posterior_std, alpha)
|
|
|
|
# Risk assessment
|
|
risk_control, risk_treatment = calculate_risk(posterior_mean, posterior_std)
|
|
|
|
return BayesianResult(
|
|
effect_size=posterior_mean,
|
|
credible_interval=(ci_lower, ci_upper),
|
|
chance_to_win=chance_win,
|
|
risk_control=risk_control,
|
|
risk_treatment=risk_treatment,
|
|
posterior_variance=posterior_variance,
|
|
ci_level=self.ci_level,
|
|
difference_type=difference_type.value,
|
|
inverse=self.inverse,
|
|
prior_mean=prior.mean,
|
|
prior_variance=prior.variance,
|
|
proper_prior=prior.proper,
|
|
)
|
|
|
|
except StatisticError:
|
|
# Re-raise StatisticError with original traceback
|
|
raise
|
|
except Exception as e:
|
|
raise StatisticError(f"Bayesian test calculation failed: {str(e)}") from e
|
|
|
|
|
|
class BayesianProportionTest(BayesianTest):
|
|
"""
|
|
Bayesian test for proportion/conversion rate metrics using Normal approximation.
|
|
|
|
Uses a Gaussian approximation to the binomial distribution for effect size inference.
|
|
This approximation is valid when np > 5 and n(1-p) > 5 for both groups.
|
|
|
|
For exact Bayesian inference on proportions, a Beta-Binomial conjugate model
|
|
would be more appropriate, but is not implemented in this version.
|
|
"""
|
|
|
|
@property
|
|
def test_name(self) -> str:
|
|
return "bayesian_proportion_test"
|
|
|
|
def run_test(
|
|
self,
|
|
treatment_stat: SampleMeanStatistic | ProportionStatistic | RatioStatistic,
|
|
control_stat: SampleMeanStatistic | ProportionStatistic | RatioStatistic,
|
|
prior: GaussianPrior,
|
|
difference_type: DifferenceType = DifferenceType.RELATIVE,
|
|
**kwargs,
|
|
) -> BayesianResult:
|
|
"""
|
|
Run Bayesian test for proportion data using Normal approximation.
|
|
|
|
Args:
|
|
treatment_stat: Treatment group proportion statistic
|
|
control_stat: Control group proportion statistic
|
|
prior: Gaussian prior for effect size
|
|
difference_type: Type of difference to calculate
|
|
**kwargs: Additional parameters
|
|
|
|
Returns:
|
|
BayesianResult with proportion-specific interpretations
|
|
|
|
Raises:
|
|
StatisticError: If Normal approximation is not valid
|
|
"""
|
|
# Validate proportion-specific inputs
|
|
if not isinstance(treatment_stat, ProportionStatistic):
|
|
raise StatisticError("Treatment statistic must be ProportionStatistic")
|
|
|
|
if not isinstance(control_stat, ProportionStatistic):
|
|
raise StatisticError("Control statistic must be ProportionStatistic")
|
|
|
|
# Check Normal approximation validity
|
|
self._validate_normal_approximation(treatment_stat, control_stat)
|
|
|
|
# Use the general Bayesian Gaussian test implementation
|
|
gaussian_test = BayesianGaussianTest(ci_level=self.ci_level, inverse=self.inverse)
|
|
result = gaussian_test.run_test(treatment_stat, control_stat, prior, difference_type, **kwargs)
|
|
|
|
# Update test type for result tracking
|
|
result.difference_type = f"proportion_{difference_type.value}"
|
|
|
|
return result
|
|
|
|
def _validate_normal_approximation(
|
|
self, treatment_stat: ProportionStatistic, control_stat: ProportionStatistic
|
|
) -> None:
|
|
"""
|
|
Validate that Normal approximation to binomial is appropriate.
|
|
|
|
Checks the rule of thumb: np > 5 and n(1-p) > 5 for both groups.
|
|
"""
|
|
for name, stat in [("treatment", treatment_stat), ("control", control_stat)]:
|
|
successes = stat.sum
|
|
failures = stat.n - stat.sum
|
|
|
|
if successes < 5:
|
|
raise StatisticError(
|
|
f"Normal approximation invalid: {name} has only {successes} successes "
|
|
f"(need ≥5). Consider using exact binomial methods."
|
|
)
|
|
|
|
if failures < 5:
|
|
raise StatisticError(
|
|
f"Normal approximation invalid: {name} has only {failures} failures "
|
|
f"(need ≥5). Consider using exact binomial methods."
|
|
)
|
|
|
|
|
|
class BayesianMeanTest(BayesianTest):
|
|
"""
|
|
Bayesian test for continuous mean metrics using Gaussian conjugate priors.
|
|
|
|
This test is appropriate for continuous metrics where the effect size
|
|
can be modeled as normally distributed. The Gaussian conjugate prior
|
|
assumption is well-suited for means when sample sizes are large enough
|
|
for the Central Limit Theorem to apply.
|
|
"""
|
|
|
|
@property
|
|
def test_name(self) -> str:
|
|
return "bayesian_mean_test"
|
|
|
|
def run_test(
|
|
self,
|
|
treatment_stat: SampleMeanStatistic | ProportionStatistic | RatioStatistic,
|
|
control_stat: SampleMeanStatistic | ProportionStatistic | RatioStatistic,
|
|
prior: GaussianPrior,
|
|
difference_type: DifferenceType = DifferenceType.RELATIVE,
|
|
**kwargs,
|
|
) -> BayesianResult:
|
|
"""
|
|
Run Bayesian test for continuous mean data.
|
|
|
|
Args:
|
|
treatment_stat: Treatment group mean statistic
|
|
control_stat: Control group mean statistic
|
|
prior: Gaussian prior for effect size
|
|
difference_type: Type of difference to calculate
|
|
**kwargs: Additional parameters
|
|
|
|
Returns:
|
|
BayesianResult with mean-specific interpretations
|
|
"""
|
|
# Validate mean-specific inputs
|
|
if not isinstance(treatment_stat, SampleMeanStatistic):
|
|
raise StatisticError("Treatment statistic must be SampleMeanStatistic")
|
|
|
|
if not isinstance(control_stat, SampleMeanStatistic):
|
|
raise StatisticError("Control statistic must be SampleMeanStatistic")
|
|
|
|
# Use the general Bayesian Gaussian test implementation
|
|
gaussian_test = BayesianGaussianTest(ci_level=self.ci_level, inverse=self.inverse)
|
|
result = gaussian_test.run_test(treatment_stat, control_stat, prior, difference_type, **kwargs)
|
|
|
|
# Update test type for result tracking
|
|
result.difference_type = f"mean_{difference_type.value}"
|
|
|
|
return result
|