Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions frontend/src/scenes/experiments/ExperimentView/Info.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { CONCLUSION_DISPLAY_CONFIG } from '../constants'
import { experimentLogic, previousRefreshAnalytics } from '../experimentLogic'
import { getExperimentStatus, isExperimentPaused } from '../experimentsLogic'
import { modalsLogic } from '../modalsLogic'
import { formatStatsLevelPercent, getExperimentStatsLevel } from '../utils'
import { ExperimentDuration } from './ExperimentDuration'
import { ExperimentReloadAction } from './ExperimentReloadAction'
import { RunningTime } from './RunningTime'
Expand Down Expand Up @@ -134,9 +135,7 @@ export function Info(): JSX.Element {
<span>
{statsMethod === ExperimentStatsMethod.Bayesian ? 'Bayesian' : 'Frequentist'}
{' / '}
{statsMethod === ExperimentStatsMethod.Bayesian
? `${((experiment.stats_config?.bayesian?.ci_level ?? 0.95) * 100).toFixed(0)}%`
: `${((1 - (experiment.stats_config?.frequentist?.alpha ?? 0.05)) * 100).toFixed(0)}%`}
{formatStatsLevelPercent(getExperimentStatsLevel(experiment))}
</span>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { ExperimentStatsMethod, PropertyFilterType, PropertyOperator } from '~/t
import { DEFAULT_LOOKBACK_DAYS } from '../constants'
import { experimentLogic } from '../experimentLogic'
import { modalsLogic } from '../modalsLogic'
import { formatStatsLevelPercent, getExperimentStatsLevel } from '../utils'
import { getCupedSelection, resolveCupedEnabled, resolveCupedLookbackDays } from './cuped'
import { CupedModal } from './CupedModal'
import { resolveSequentialEnabled } from './sequential'
Expand All @@ -25,9 +26,7 @@ export function SettingsTab(): JSX.Element {

const isBayesian = statsMethod === ExperimentStatsMethod.Bayesian

const confidenceDisplay = isBayesian
? `${((experiment.stats_config?.bayesian?.ci_level ?? 0.95) * 100).toFixed(0)}%`
: `${((1 - (experiment.stats_config?.frequentist?.alpha ?? 0.05)) * 100).toFixed(0)}%`
const confidenceDisplay = formatStatsLevelPercent(getExperimentStatsLevel(experiment))

const teamDefaultCupedEnabled = experimentsConfig?.default_cuped_enabled ?? false
const teamDefaultCupedLookbackDays = experimentsConfig?.default_cuped_lookback_days ?? null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { StatsMethodSelector } from '../components/StatsMethodSelector'
import { CONFIDENCE_LEVEL_OPTIONS } from '../constants'
import { experimentLogic } from '../experimentLogic'
import { modalsLogic } from '../modalsLogic'
import { getExperimentStatsLevel } from '../utils'
import {
DEFAULT_SEQUENTIAL_TUNING_PARAMETER,
MAX_SEQUENTIAL_TUNING_PARAMETER,
Expand All @@ -35,9 +36,7 @@ export function StatsMethodModal(): JSX.Element {

// For Bayesian: ci_level (default 0.95)
// For Frequentist: confidence = 1 - alpha (default alpha 0.05 = 95% confidence)
const currentConfidenceLevel = isBayesian
? (experiment.stats_config?.bayesian?.ci_level ?? 0.95)
: 1 - (experiment.stats_config?.frequentist?.alpha ?? 0.05)
const currentConfidenceLevel = getExperimentStatsLevel(experiment)

const handleConfidenceLevelChange = (value: number): void => {
if (isBayesian) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,14 @@ import { themeLogic } from '~/layout/navigation-3000/themeLogic'
import { ExperimentStatsMethod } from '~/types'

import { experimentLogic } from '../../experimentLogic'
import { formatStatsLevelPercent, getExperimentStatsLevel } from '../../utils'

export function HowToReadTooltip(): JSX.Element {
const { statsMethod } = useValues(experimentLogic)
const { experiment, statsMethod } = useValues(experimentLogic)
const { isDarkModeOn } = useValues(themeLogic)

const statsLevel = formatStatsLevelPercent(getExperimentStatsLevel(experiment))

return (
<>
<LemonDivider vertical className="mx-2" />
Expand Down Expand Up @@ -64,8 +67,8 @@ export function HowToReadTooltip(): JSX.Element {
<p className="mb-3">
The bars show{' '}
{statsMethod === ExperimentStatsMethod.Bayesian
? '95% credible intervals'
: '95% confidence intervals'}
? `${statsLevel} credible intervals`
: `${statsLevel} confidence intervals`}
. When an interval doesn't cross the 0% line, the result is significant.
</p>
<img
Expand Down
21 changes: 17 additions & 4 deletions frontend/src/scenes/experiments/MetricsView/new/ResultDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { humanFriendlyNumber } from 'lib/utils'
import { FunnelChart } from 'scenes/experiments/charts/funnel/FunnelChart'
import { experimentLogic } from 'scenes/experiments/experimentLogic'
import { VariantTag } from 'scenes/experiments/ExperimentView/VariantTag'
import { getViewRecordingFilters } from 'scenes/experiments/utils'
import { formatStatsLevelPercent, getExperimentStatsLevel, getViewRecordingFilters } from 'scenes/experiments/utils'

import {
CachedNewExperimentQueryResponse,
Expand All @@ -25,6 +25,7 @@ import {
import {
EntityType,
Experiment,
ExperimentStatsMethod,
FilterLogicalOperator,
FunnelStep,
FunnelStepWithNestedBreakdown,
Expand Down Expand Up @@ -178,6 +179,20 @@ export function ResultDetails({

const baselineKey = result.baseline?.key

// Match the interval label and its level to the result's method so the displayed percentage
// reflects the experiment's configured statistics level (credible level for Bayesian, 1 - alpha
// for frequentist) rather than a hardcoded 95%.
const firstVariantResult = result.variant_results?.[0]
const intervalStatsMethod = firstVariantResult
? isBayesianResult(firstVariantResult)
? ExperimentStatsMethod.Bayesian
: ExperimentStatsMethod.Frequentist
: undefined
const intervalLabel = firstVariantResult ? getIntervalLabel(firstVariantResult) : 'Confidence interval'
const intervalColumnTitle = `${intervalLabel} (${formatStatsLevelPercent(
getExperimentStatsLevel(experiment, intervalStatsMethod)
)})`

const columns: LemonTableColumns<ExperimentVariantResult & { key: string }> = [
{
key: 'variant',
Expand Down Expand Up @@ -233,9 +248,7 @@ export function ResultDetails({
},
{
key: 'interval',
title: result.variant_results?.[0]
? `${getIntervalLabel(result.variant_results[0])} (95%)`
: 'Confidence interval (95%)',
title: intervalColumnTitle,
render: (_, item: ExperimentVariantResult & { key: string }) => {
if (item.key === baselineKey) {
return '—'
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/scenes/experiments/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ export const CONFIDENCE_LEVEL_OPTIONS = [
{ value: 0.99, label: '99%' },
]

// Defaults used when an experiment hasn't explicitly configured its statistics level.
// Bayesian stores the credible-interval level directly (ci_level); frequentist stores the
// significance level (alpha), so the displayed confidence level is 1 - alpha.
export const DEFAULT_BAYESIAN_CI_LEVEL = 0.95
export const DEFAULT_FREQUENTIST_ALPHA = 0.05

export const EXPERIMENT_MIN_EXPOSURES_FOR_RESULTS = 50
export const EXPERIMENT_MIN_METRIC_VALUE_FOR_RESULTS = 10

Expand Down
92 changes: 92 additions & 0 deletions frontend/src/scenes/experiments/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
AccessControlLevel,
Experiment,
ExperimentMetricMathType,
ExperimentStatsMethod,
FeatureFlagBucketingIdentifier,
FeatureFlagEvaluationRuntime,
FeatureFlagType,
Expand All @@ -32,7 +33,9 @@ import {
exposureConfigToFilter,
featureFlagEligibleForExperiment,
filterToExposureConfig,
formatStatsLevelPercent,
getEventCountQuery,
getExperimentStatsLevel,
getOrderedMetricsWithResults,
getViewRecordingFilters,
getViewRecordingFiltersLegacy,
Expand Down Expand Up @@ -1382,4 +1385,93 @@ describe('getEventCountQuery', () => {

expect(query).toBeNull()
})

describe('getExperimentStatsLevel', () => {
const buildExperiment = (statsConfig: Experiment['stats_config']): Experiment =>
({ stats_config: statsConfig }) as Experiment

it.each([
['bayesian 90%', { method: ExperimentStatsMethod.Bayesian, bayesian: { ci_level: 0.9 } }, 0.9],
['bayesian 95%', { method: ExperimentStatsMethod.Bayesian, bayesian: { ci_level: 0.95 } }, 0.95],
['bayesian 99%', { method: ExperimentStatsMethod.Bayesian, bayesian: { ci_level: 0.99 } }, 0.99],
[
'bayesian missing ci_level defaults to 95%',
{ method: ExperimentStatsMethod.Bayesian, bayesian: {} },
0.95,
],
['bayesian missing bayesian key defaults to 95%', { method: ExperimentStatsMethod.Bayesian }, 0.95],
[
'frequentist 90% (alpha 0.1)',
{ method: ExperimentStatsMethod.Frequentist, frequentist: { alpha: 0.1 } },
0.9,
],
[
'frequentist 95% (alpha 0.05)',
{ method: ExperimentStatsMethod.Frequentist, frequentist: { alpha: 0.05 } },
0.95,
],
[
'frequentist 99% (alpha 0.01)',
{ method: ExperimentStatsMethod.Frequentist, frequentist: { alpha: 0.01 } },
0.99,
],
[
'frequentist missing alpha defaults to 95%',
{ method: ExperimentStatsMethod.Frequentist, frequentist: {} },
0.95,
],
[
'frequentist missing frequentist key defaults to 95%',
{ method: ExperimentStatsMethod.Frequentist },
0.95,
],
])('resolves the configured level for %s', (_name, statsConfig, expected) => {
expect(getExperimentStatsLevel(buildExperiment(statsConfig as Experiment['stats_config']))).toBeCloseTo(
expected,
10
)
})

it.each([
['undefined stats_config', undefined],
['null stats_config', null],
['empty stats_config', {}],
])('defaults to Bayesian 95% when %s', (_name, statsConfig) => {
expect(getExperimentStatsLevel(buildExperiment(statsConfig as Experiment['stats_config']))).toBeCloseTo(
0.95,
10
)
})

it('honors an explicit statsMethod override (e.g. matching a result method)', () => {
// Experiment is configured Bayesian, but the override forces frequentist resolution.
const experiment = buildExperiment({
method: ExperimentStatsMethod.Bayesian,
bayesian: { ci_level: 0.9 },
frequentist: { alpha: 0.2 },
} as Experiment['stats_config'])

expect(getExperimentStatsLevel(experiment)).toBeCloseTo(0.9, 10)
expect(getExperimentStatsLevel(experiment, ExperimentStatsMethod.Frequentist)).toBeCloseTo(0.8, 10)
})
})

describe('formatStatsLevelPercent', () => {
it.each([
[0.9, '90%'],
[0.95, '95%'],
[0.99, '99%'],
[0.8, '80%'],
[0.5, '50%'],
])('formats %p as %s', (level, expected) => {
expect(formatStatsLevelPercent(level)).toBe(expected)
})

it('formats the label shown for a Bayesian 90% experiment', () => {
const experiment = {
stats_config: { method: ExperimentStatsMethod.Bayesian, bayesian: { ci_level: 0.9 } },
} as Experiment
expect(formatStatsLevelPercent(getExperimentStatsLevel(experiment))).toBe('90%')
})
})
})
31 changes: 30 additions & 1 deletion frontend/src/scenes/experiments/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
Experiment,
ExperimentMetricGoal,
ExperimentMetricMathType,
ExperimentStatsMethod,
FeatureFlagType,
FilterType,
FunnelConversionWindowTimeUnit,
Expand All @@ -45,11 +46,39 @@ import {
UniversalFiltersGroupValue,
} from '~/types'

import { EXPERIMENT_VARIANT_MULTIPLE } from './constants'
import { DEFAULT_BAYESIAN_CI_LEVEL, DEFAULT_FREQUENTIST_ALPHA, EXPERIMENT_VARIANT_MULTIPLE } from './constants'
import { SharedMetric } from './SharedMetrics/sharedMetricLogic'

const MULTIPLE_VARIANT_WARNING_THRESHOLD = 0.5 // on the 0-100 scale (0.5 = 0.5%)

/**
* Resolve an experiment's configured statistics method, defaulting to Bayesian when unset.
*/
export function getExperimentStatsMethod(experiment: Experiment): ExperimentStatsMethod {
return experiment.stats_config?.method || ExperimentStatsMethod.Bayesian
}

/**
* Resolve the configured statistics level (as a 0-1 fraction) for an experiment. Bayesian stores
* the credible-interval level directly (`ci_level`); frequentist stores the significance level
* (`alpha`), so the displayed confidence level is `1 - alpha`. Falls back to the 95% default when
* the setting is unset. Pass `statsMethod` to resolve the level for a specific method (e.g. to match
* a result's method) instead of the experiment's configured method.
*/
export function getExperimentStatsLevel(experiment: Experiment, statsMethod?: ExperimentStatsMethod): number {
const method = statsMethod ?? getExperimentStatsMethod(experiment)
return method === ExperimentStatsMethod.Bayesian
? (experiment.stats_config?.bayesian?.ci_level ?? DEFAULT_BAYESIAN_CI_LEVEL)
: 1 - (experiment.stats_config?.frequentist?.alpha ?? DEFAULT_FREQUENTIST_ALPHA)
}

/**
* Format a 0-1 statistics level as a whole-number percentage, e.g. 0.9 -> "90%".
*/
export function formatStatsLevelPercent(level: number): string {
return `${(level * 100).toFixed(0)}%`
}

export function filterLowMultipleVariant<T extends { variant: string; percentage: number }>(variants: T[]): T[] {
return variants.filter(
(v) => v.variant !== EXPERIMENT_VARIANT_MULTIPLE || v.percentage > MULTIPLE_VARIANT_WARNING_THRESHOLD
Expand Down
Loading