Run the calibration test¶
This guide runs the matched Monte Carlo test and shows how to read its p-value. The test asks whether structureless copy tables of your table score as high as your table does.
Prerequisites¶
A result from
starfold.recoverwith the test off (the default), its tableX, and theconfig,random_stateandengineof that run.Time. One copy costs 102 fits at the default budgets (17 trials x 3 resamples x 2 tables, the copy needing a copy of its own). Up to 19 copies cost up to 1,938 fits, about six times the rest of a run.
What the test answers¶
Each copy is drawn from the declared family and goes through the first 17 trials of the same search. The p-value is the share of copies that reach a gap at least as high as your table’s (your table counts as one more draw). If your table is a draw from the declared family, p is valid: the chance that p falls at or below alpha is at most alpha. It is not spread evenly over its possible values, because the test stops at the third copy that reaches your table. It holds only for the declared family of copies and the procedure that was simulated. It is not the probability that the groups are real, and it says nothing about how many groups exist. See null hypothesis.
Steps¶
Keep the rows
recoverkept.null_testrefuses a matrix that has a non-finite value.import starfold Xk = X[result.kept_rows]
Choose the family of copies. The default is
("gaussian_copula",).Family
Keeps
Destroys
Tests against
gaussian_copulaevery column’s values and a fitted, shrunk Gaussian dependence between columns
structure that needs several columns together
one population whose columns keep their shapes and are linked only by a Gaussian dependence
independent_marginalsevery column’s values
all dependence between columns
columns with no relation
Neither family removes multimodality within a single column: a column with two peaks keeps both peaks in every copy. A Gaussian copula copy can score almost as high as your table when your groups show up only as a correlation between columns.
independent_marginalsis easier to beat. With several families,null.p_valueis the largest of their p-values. Every value in a copy is a value you measured, only reordered.Choose
alphaandn_null_maxin theConfig, the sameConfigas the run. The test needsn_null_maxof at leastceil(1 / alpha) - 1(19 atalpha=0.05), or building theConfigraisesValueError.null_testhas no arguments for these two, so change them withdataclasses.replace.import dataclasses config = starfold.Config() # the config of the run you calibrate config = dataclasses.replace(config, n_null_max=99) # optional, lowers the floor to p = 0.01
With 99 copies the cost rises to up to 10,098 fits. Neither field changes the search, so the replay stays matched.
Run the test.
null = starfold.null_test( Xk, result.search, config=config, families=("gaussian_copula",), random_state=result.provenance["random_state"], engine=result.provenance["engine_resolved"], ) print(null.p_value, null.p_intervals, null.structure_supported)
The config (with its
Space),random_stateandenginemust equal those of the run, so the replay uses the same procedure and seeds. The call reads the last two from the run’s record.null_testdefaults toengine="auto"andrandom_state=0, which do not match a run on another engine or seed. Above 20,000 rows per resample,n_jobsmust match too (Run fits in parallel with n_jobs).Read the result.
null.observedis the largest search gap (score on your table minus score on a copy) among the 17 opening trials: the defaults and the 16-trial Sobol design. For Digits, 0.710 is the search gap of trial 7. It is not the best score of the full search.null.n_realisationscounts the copies drawn andnull.exceedancesthe copies at or above your table. Withhexceedances inLcopies,p = (h + 1) / (L + 1).Copies
Lp for h = 0
h = 1
h = 2
h = 3
4
0.200
0.400
0.600
0.800
9
0.100
0.200
0.300
0.400
19
0.050
0.100
0.150
0.200
99
0.010
0.020
0.030
0.040
At any
n_null_max, the test stops once 3 copies reach your table. At the default 19 copies its p is then at least 4 / 20 = 0.2, above any usualalpha, so more copies would only refine a p that is already too high. The 0.05 floor holds at 19 copies only. At 99 copies the floor is 0.01, and if the third copy to reach your table is the 99th, p = 0.04.structure_supportedisp_value <= alpha. It is a statement about copies, never a probability that groups are real.null.p_intervalsis a 95 percent Clopper-Pearson interval on the share of copies at or above your table. It measures the Monte Carlo precision of 19 copies and nothing else.Attach the test to the saved result, so the flags are re-derived. A result from
recoverwith the test off carriesnull_not_runand a placeholder p of 1.0.from starfold.result import derive_flags prov = result.provenance calibrated = dataclasses.replace( result, null=null, flags=derive_flags( labels=result.labels, search=result.search, worlds=result.worlds, consensus=result.consensus, null=null, backends=result.backends, rows_rejected=int(prov["n_input_rows"]) - int(prov["n_kept_rows"]), n_jobs_forced=bool(prov["n_jobs_forced_to_one_on_cuml"]), ), ) starfold.save(calibrated, "runs/digits_null")
starfold.loadre-derives the flags from the evidence and refuses a result whose stored flags differ, so the flags must be replaced in the same step.
Expected result¶
The recorded 19-copy runs on two tables, read from bench/results/digits_null.json and bench/results/apogee_null.json, summarised in two lines:
digits observed 0.710, 19 copies, 0 at or above the data, p 0.05, fraction at or above 0.000 to 0.176 (95% Monte Carlo interval), 1938 fits, supported True
apogee observed 0.362, 19 copies, 1 at or above the data, p 0.10, fraction at or above 0.001 to 0.260 (95% Monte Carlo interval), 1938 fits, supported False
The scikit-learn digits dataset beat all 19 copies, and p sits on its floor of 0.05, which equals alpha, so the structure is supported at that level. The APOGEE table was matched by one copy (0.366 against 0.362), p is 0.10, and result.flags gains no_structure_supported. For APOGEE, report the groups as reproducible across refits and do not claim that they exceed a correlated single population. The Digits record took 791 s for 1,938 fits (the engine is not stored in the record).
Variations¶
Inside
recover. PassConfig(null_families=("gaussian_copula",)). The test then runs as part of the call and the result has the flags already.A full replay.
Config(null_procedure="full_search")replays the whole search for every copy instead of the 17 opening trials, and the statistic changes with it: it is the selected setting’s fresh score, not a gap. The power of the default test against the full-search statistic is not measured. The cost is much higher and is not measured here.A zero observed statistic. If the recorded statistic (the gap, or the fresh score under
full_search) is zero or below, the test is skipped andresult.flagscontainsobserved_statistic_zero.Sweeps. Keep the test off while you sweep seeds or tables, and run it once on the result you report (Run a seed sweep on a SLURM cluster).