report.findings is a list of dictionaries. Each entry describes a key,
dependency, association, or data-quality issue. Start with claim for a
readable summary, then inspect the columns and measurements behind it.
| Field | Contents |
|---|---|
id | Stable identifier for the same category, type, columns, and specification |
category | Kind of result, such as uniqueness or dependencies |
type | Subtype, such as a functional or predictive dependency |
subject | Columns involved; dependencies also name their inputs and target |
specification | Formula, mapping, conditions, outcome, or comparison definition |
claim | Text summary of the result |
evidence | Methods, evaluated rows, validation, and measured or fitted values |
interestingness | Score used to order findings in the report |
interpretation | Suggested action or meaning, when available |
The example adds a concentrated sales channel and mixed-format invoice numbers to an orders table.
import json
import random
import polars as pl
import onceover
rng = random.Random(7)
n = 1500
region_id = [rng.randrange(3) for _ in range(n)]
region_name = [["emea", "amer", "apac"][r] for r in region_id]
order_id = list(range(n))
external_ref = [f"R{i:05}" for i in range(n)]
external_ref[-1] = external_ref[0]
external_ref[-2] = external_ref[1]
quantity = [rng.randrange(1, 11) for _ in range(n)]
double_quantity = [2 * q for q in quantity]
channel = ["web" if rng.random() < 0.99 else "app" for _ in range(n)]
invoice_no = [f"INV-{i:04d}" if i % 2 == 0 else f"{i:04d}" for i in range(n)]
df = pl.DataFrame({
"order_id": order_id,
"external_ref": external_ref,
"region_id": region_id,
"region_name": region_name,
"quantity": quantity,
"double_quantity": double_quantity,
"channel": channel,
"invoice_no": invoice_no,
})
report = onceover.profile(df)
print(report.overview["headline"])1500 rows × 8 columns (standard budget); key candidate: order_id; 5 functional dependencies, 4 predictive dependencies, 12 conditional rules
Categories¶
The category describes the result. To identify the method that produced
it, read evidence[*].method.detector.
| Category | Type | Reports |
|---|---|---|
missingness | — | Missing-value counts, including entirely null columns |
concentration | — | Constant columns or a distribution dominated by a few values |
concentration | modal_share, spike_cells | Views’ within-cluster concentration and low-density cells |
column_roles | — | Suggested interpretations such as category codes or identifiers |
column_roles | long_format | Long-format frames: the (index, attribute, measure) triple and the pivot call that widens them |
uniqueness | — | Exact or nearly unique columns and column combinations |
duplicates | — | Repeated rows under the specified equality rule |
formats | — | Mixed string formats |
dependencies | functional | Value mappings and affine identities, with violations or tolerances |
dependencies | predictive | Prediction of a target from named inputs, including local linear models |
conditional_rules | if_then | Conditions, an outcome, and how often that outcome holds |
associations | statistical | Numeric dependence measures or views’ shared row partitions |
associations | period_association | A numeric association whose strength changes between the periods of a date or year column |
gaps | — | Missing dates or values relative to a stated sequence |
intervals | — | Irregular temporal spacing |
ordering | — | Sortedness in the existing row order |
changes | residual_level | Dated residual shifts, or views’ differences between unordered clusters |
Views is opt-in. Its cluster findings do not identify temporal change points; see the views chapter.
Count findings by category to see what this report contains:
from collections import Counter
print(Counter((f["category"], f.get("type")) for f in report.findings))Counter({('conditional_rules', 'if_then'): 12, ('dependencies', 'functional'): 5, ('dependencies', 'predictive'): 4, ('column_roles', 'identifier_role'): 3, ('column_roles', 'categorical_role'): 3, ('uniqueness', 'uniqueness'): 2, ('column_roles', 'duplicate_encoding'): 2, ('concentration', 'hotspot'): 2, ('concentration', 'value_concentration'): 1, ('associations', 'statistical'): 1, ('intervals', 'categorical_outlier'): 1, ('formats', 'format_variation'): 1, ('ordering', 'column_ordering'): 1})
Methods and scores¶
The following result predicts region_id from region_name. Both the
boosted model (gbm) and the decision table found this relationship, so
the finding contains evidence from both.
The boosted model reports accuracy on a holdout. The decision table
reports cross-validated accuracy. Inspect method.detector and
validation.method before comparing scores: the models and evaluated
rows differ.
merged = next(f for f in report.findings
if f["category"] == "dependencies"
and len(f["evidence"]) > 1)
print(merged["claim"])
for ev in merged["evidence"]:
m = ev["method"]
print(f" {m['detector']:16s} basis={ev['basis']:9s} "
f"validation={ev['validation']['method']}")region_id is predictable from [region_name] (holdout accuracy 1.00 vs 0.35 majority baseline)
decision_tables basis=estimated validation=cross_validation
gbm basis=estimated validation=diagnostic_holdout
Both methods report perfect accuracy in this fixture. That result is expected because each region name maps to exactly one region code. The functional-dependency findings check the mapping directly and report its violation count.
Conditional rules also include discovery and evaluation results. In the
next example, PRISM finds that double_quantity <= 2 implies quantity = 1.
The rule evaluator then counts matches on its diagnostic rows:
rule = next(f for f in report.findings if f["category"] == "conditional_rules")
print(rule["claim"])
for ev in rule["evidence"]:
print(f" {ev['method']['detector']:16s} basis={ev['basis']:9s} "
f"validation={ev['validation']['method']}")
print(" evaluated metrics:", {k: v for k, v in rule["evidence"][-1]["metrics"].items()
if k in ("conditional_rate", "baseline_rate", "lift", "joint_count")})When double_quantity <= 2, quantity = 1 holds in 38 of 38 matching diagnostic rows (100.00%; baseline 11.80%)
prism basis=estimated validation=none
rule_evaluator basis=measured validation=diagnostic_partition
evaluated metrics: {'baseline_rate': 0.11801242236024845, 'conditional_rate': 1.0, 'joint_count': 38, 'lift': 8.473684210526315}
The rule holds in 38 of 38 matching diagnostic rows. Its baseline rate is
11.80%, so the reported lift is approximately 8.47. These are the
rule_evaluator metrics. PRISM’s construction score describes the search
that produced the rule and should not be used as a replacement for those
counts.
Finding identifiers¶
IDs are derived from the category, type, subject, and specification. Use them to match the same finding across reports generated with the same schema. They do not encode rank or detector order.
Ranking¶
interestingness is a priority score. It is the mean of two scores:
the detector’s own score, preserved on the finding as
detector_interestingness, and a ledger evidence score that combines
evidence quality, effect strength, specificity, and simplicity:
Quality: full-data direct comparisons score 1.0; holdouts, cross-validation, and diagnostic partitions score 0.8. Other evidence uses its basis: measured 0.85, estimated 0.6, inferred 0.4.
Strength: the metric depends on the category. Examples include the proportion of rows satisfying a functional dependency, predictive performance, and a rule’s improvement over its baseline.
Specificity: a specified form, such as an affine formula, scores 1.0; an unspecified form scores 0.5.
Simplicity: fewer inputs and conditions score higher, using .
The chapter’s exact dependency region_id → region_name scores 0.7448.
Its ledger score is 0.9889 — quality, strength, and specificity each
score 1.0, while its one input gives simplicity 0.889 — and its detector
score is 0.5007, because the mapping was verified over only 3 distinct
inputs in 1500 rows. A score near one is not a probability of correctness.
A finding can also be demoted for triviality: when a statement
restates the file’s own structure, its selection score is multiplied
by a factor (0.6 per reason) and its evidence records triviality
(the reasons) and triviality_factor. Demoted findings are never
removed, only ranked lower. The reasons in use: an FD whose inputs
contain an exact unique key (implied_by_unique_key); a float
column presented as a key candidate
(continuous_measure_key_candidate); a format mix that differs
only in letter capitalization (capitalization_only_format_mix);
and an FD from a long-format frame’s measure column to its
attribute column (long_format_encoding), which holds because the
levels occupy disjoint value ranges.
Report size¶
The selector sorts candidates and applies these limits:
| Setting | Default | Limit |
|---|---|---|
max_findings | 100 | Total findings |
max_rules | 20 | Conditional rules |
rules_per_outcome | 2 | Rules for the same outcome, such as shipping = "express" |
predictive_findings_per_target | 3 | Predictive findings for one target |
Python exposes max_findings and max_rules. Rust’s SelectionPolicy
exposes all four. report.data["selection"] shows how many candidates and
distinct findings were considered and how many were included.
Related findings¶
report.groups connects findings that share columns. For example,
quantity and double_quantity appear together with the findings about
their relationship. Before removing a column as redundant, check whether
the other columns determine it and whether the mapping has exceptions.
Filter findings¶
Select the categories relevant to your task:
structure = [f for f in report.findings
if f["category"] in ("uniqueness", "duplicates", "dependencies",
"conditional_rules")]
print(len(structure), "structural findings")
for f in structure[:5]:
print(f" [{f['category']}/{f.get('type', '-')}] {f['claim'][:70]}")23 structural findings
[uniqueness/uniqueness] order_id uniquely identifies rows (primary key candidate)
[dependencies/functional] double_quantity = 2.000000·quantity within tolerance on all 1500 non-m
[uniqueness/uniqueness] column external_ref is nearly unique with 2 repeated non-null values
[dependencies/predictive] region_id is predictable from [region_name] (holdout accuracy 1.00 vs
[conditional_rules/if_then] When double_quantity <= 2, quantity = 1 holds in 38 of 38 matching dia
To inspect a particular column, filter on subject.columns:
for f in report.findings:
if "quantity" in f["subject"]["columns"]:
print(f"[{f['category']}/{f.get('type', '-')}] {f['claim'][:80]}")[dependencies/functional] double_quantity = 2.000000·quantity within tolerance on all 1500 non-missing row
[conditional_rules/if_then] When double_quantity <= 2, quantity = 1 holds in 38 of 38 matching diagnostic ro
[conditional_rules/if_then] When quantity <= 1, double_quantity = 2 holds in 38 of 38 matching diagnostic ro
[conditional_rules/if_then] When double_quantity > 4 and double_quantity <= 6, quantity = 3 holds in 34 of 3
[conditional_rules/if_then] When double_quantity > 8 and double_quantity <= 10, quantity = 5 holds in 40 of
[conditional_rules/if_then] When quantity > 2 and quantity <= 3, double_quantity = 6 holds in 34 of 34 match
[conditional_rules/if_then] When quantity > 4 and quantity <= 5, double_quantity = 10 holds in 40 of 40 matc
[dependencies/predictive] quantity is predictable from [double_quantity] (holdout accuracy 1.00 vs 0.11 ma
[dependencies/predictive] double_quantity is predictable from [quantity] (holdout accuracy 1.00 vs 0.12 ma
[column_roles/duplicate_encoding] quantity and double_quantity encode the same grouping: 10 levels, paired one-to-
[associations/statistical] quantity and double_quantity show numeric association (maximum measured effect 1
[dependencies/functional] double_quantity → quantity (quantity is a function of double_quantity; 0 violati
[dependencies/functional] quantity → double_quantity (double_quantity is a function of quantity; 0 violati
[intervals/categorical_outlier] within quantity = 1 (170 rows), channel is web in 99.4% of rows — 1 row breaks t
[concentration/hotspot] cell (quantity = '(7, 8]', double_quantity = '(14, 16]') holds 139 of 1500 scann
[column_roles/categorical_role] numeric column quantity behaves like a category (10 distinct values)
To select findings with measurements over the full table, inspect each
evidence record’s basis and scope.sampling:
measured = [f for f in report.findings
if any(ev["basis"] == "measured" and ev["scope"]["sampling"] == "full"
for ev in f["evidence"])]
print(len(measured), "statements measured on full data")12 statements measured on full data
For a model result, also inspect validation.method. Cross-validation,
a holdout, and a diagnostic partition use different row sets and can
participate in model or finding selection. The
evidence chapter walks through an example.
Acting on a finding¶
interpretation suggests possible actions. For example, a duplicate-row
finding may suggest checking for repeated imports. Confirm the intended
meaning of the rows before deleting them. Similarly, verify a column’s
intended key or code semantics before changing a join or model.
For use on new data, test predictive results on observations that were not used to fit or select the model. A diagnostic-partition rate describes the recorded rows and may reuse discovery data.
If an expected finding is absent, inspect coverage. It lists skipped passes and search limits; an absent finding alone does not establish that the columns are independent.
Select detectors¶
onceover.available_detectors() lists detector IDs, output categories,
and required detectors. Pass IDs in detectors=[...] to select which
findings are requested:
for d in onceover.available_detectors()[:4]:
print(f"{d['id']:24s} categories={d['categories']} requires={d['requires']}")
subset = onceover.profile(df, detectors=["keys", "functional_dependencies"])
print(subset.overview["headline"])column_profile categories=['missingness', 'concentration', 'column_roles', 'formats'] requires=[]
row_duplicates categories=['duplicates'] requires=[]
keys categories=['uniqueness'] requires=[]
pivot categories=['column_roles'] requires=[]
1500 rows × 8 columns (standard budget); key candidate: order_id; 4 functional dependencies, 0 predictive dependencies, 0 conditional rules
Some selected detectors need another detector’s output. Decision tables,
for example, use target profiles from gbm. The required pass runs to
supply that data, but contributes findings only if it was also selected.
Coverage identifies these required passes and the passes that were not
enabled.
Findings as a DataFrame¶
report.findings_frame() returns the ledger as a Polars DataFrame:
one row per finding with id, category, type, columns, inputs,
target, interestingness, claim, n_evidence, and the primary
evidence record’s basis, in report order. Sorting and filtering the
frame is the programmatic counterpart to reading the Markdown report,
which prints each finding’s evidence directly under its claim.
frame = report.findings_frame()
print(frame.height, "findings")
print(frame.select(["category", "interestingness", "claim"]).head(3))38 findings
┌───────────────┬─────────────────┬─────────────────────────────────┐
│ category ┆ interestingness ┆ claim │
╞═══════════════╪═════════════════╪═════════════════════════════════╡
│ uniqueness ┆ 0.95 ┆ order_id uniquely identifies r… │
│ dependencies ┆ 0.8695 ┆ double_quantity = 2.000000·qua… │
│ concentration ┆ 0.8308 ┆ column channel is highly conce… │
└───────────────┴─────────────────┴─────────────────────────────────┘
Long-format frames: the pivot finding¶
One finding describes the frame rather than a relation inside it.
When a table is in long format — one row per (index, attribute) pair,
with a single measure column — the pivot detector reports the
triple and the pivot that widens it: a column_roles finding of type
long_format, form pivot, whose definition names index,
attribute, and measure.
Recognition is verified, not inferred from names. The frame must have exactly one continuous measure column (several continuous columns are read as an already-wide frame). The attribute is a categorical column with 2 to 32 levels, no missing values, and every level repeated at least twice; the index is every remaining column, also complete. The candidate is verified by checking that (index, attribute) is jointly unique over all rows. Frames under 30 rows are not scanned, and at most three pivot findings are reported; when two attributes complete the same key with the same measure, only the reading with fewer levels is kept, because levels become columns.
The evidence records n_rows, n_index_tuples, n_levels, the
level names, and rectangularity — rows divided by (index tuples ×
levels), which is 1.0 for a complete panel and below it when some
(index, level) cells are absent. The finding is advice: the frame
the other detectors see is unchanged. Executing the recipe is the
reader’s step, and it is mechanical, because the definition carries
the three arguments df.pivot needs.
rows = {"entity": [], "year": [], "indicator": [], "value": []}
for e in range(6):
for y in range(5):
for ii, ind in enumerate(["gdp_pc", "life_exp", "co2_pc"]):
rows["entity"].append(f"E{e}")
rows["year"].append(2019 + y)
rows["indicator"].append(ind)
rows["value"].append(100.0 * e + 10.0 * y + 30.0 * ii)
long = pl.DataFrame(rows)
report = onceover.profile(long)
hit = next(f for f in report.findings
if f["specification"].get("form") == "pivot")
print(hit["claim"])
spec = hit["specification"]["definition"]
print("definition:", spec)
wide = long.pivot(on=spec["attribute"], index=spec["index"],
values=spec["measure"])
print("wide shape:", wide.shape)
print("wide columns:", wide.columns)frame is in long format: (entity, year) × indicator → value; pivoting on indicator gives one column per level (co2_pc, gdp_pc, life_exp)
definition: {'attribute': 'indicator', 'index': ['entity', 'year'], 'measure': 'value', 'missing_values': 'index and attribute columns complete; measure values may be missing'}
wide shape: (30, 5)
wide columns: ['entity', 'year', 'gdp_pc', 'life_exp', 'co2_pc']
The wide frame itself produces no pivot finding — with three measure columns, there is no single measure left to widen around. A sparse long frame still reports, with its rectangularity below 1.0 stating how much of the panel is present.
The relationships between the levels exist only in the wide form,
so at the standard and deep budgets the profiler does not stop at
the pivot finding: it profiles the pivoted frame as well and merges
its findings into the same report. They are tagged
source_form: "pivoted wide form" in their evidence metrics, and
the coverage entry pivot_profile states the wide form’s shape and
how many findings it contributed. The quick budget skips the wide
profile, and so do wide forms over 64 columns or under 8 index
tuples.
wide_hits = [f for f in report.findings
if any(e["metrics"].get("source_form") == "pivoted wide form"
for e in f["evidence"])]
print(report.data["coverage"]["pivot_profile"]["detail"])
for f in wide_hits[:3]:
print(f["category"], f["type"], "-", f["claim"])
profiled the pivoted wide form (2 index columns, 3 levels of indicator -> 30 rows x 5 columns); 5 findings included, tagged source_form in their evidence
associations statistical - co2_pc and gdp_pc show numeric association (maximum measured effect 1.000)
associations statistical - co2_pc and life_exp show numeric association (maximum measured effect 1.000)
associations statistical - gdp_pc and life_exp show numeric association (maximum measured effect 1.000)
Drift findings: periods and the permutation null¶
The drift detector engages only on a declared time axis. Select it explicitly or let the standard selection include it, and declare the axis with hints. Its findings carry one evidence entry whose metrics hold the per-period scores (periods, each with range, drift, and concept_drift), the reference period’s holdout fit (reference_r2), the largest period score (max_score, at max_period), and null_max — the largest maximum produced by permuting the period labels and rescoring, the no-drift bar the real maximum had to beat. A target whose reference model does not reach a holdout R² of 0.2 produces no finding: there is no stable relationship whose drift could be scored.
import random
import polars as pl
rng = random.Random(9)
rows = {"t": [], "x": [], "y": []}
for i in range(4000):
x = rng.uniform(-1, 1) + (2.0 if i >= 2000 else 0.0)
rows["t"].append(i)
rows["x"].append(x)
rows["y"].append(2 * x + rng.gauss(0, 0.4))
drift_report = onceover.profile(pl.DataFrame(rows), hints={"time": "t"})
f = next(f for f in drift_report.findings if f.get("type") == "distribution_shift")
print(f["claim"])
m = f["evidence"][0]["metrics"]
print("null_max:", round(m["null_max"], 1))
for p in m["periods"]:
print(f"period {p['period']}: drift {p['drift']:.1f}")the predictors of y drift over t: split-routing drift score reaches 323.6 in period 4 (2000 to 2499), against a no-drift scale of ~1
null_max: 2.8
period 1: drift 1.0
period 2: drift 0.3
period 3: drift 1.3
period 4: drift 323.6
period 5: drift 323.6
period 6: drift 323.6
period 7: drift 323.6
Evidence payloads: the table inside a finding¶
Some finding types carry a bespoke payload in their evidence
metrics: a small table, shaped for that type, that lets the claim
be inspected without a follow-up query. The scalar metrics always
carry the full counts; the payload is a capped sample of the rows
behind them, and it renders as a table under the finding in
Markdown.
The functional-dependency payload is mapping: for an approximate
dependency, the keys that map to more than one value, worst first
(up to 8 keys, 3 values per key), with mapping_ambiguous_keys and
mapping_keys giving the totals; for an exact dependency, the 8
largest keys. Break findings carry segments — the segmentation
each breakpoint was drawn from — duplicate-row findings carry
duplicate_groups with the largest groups’ values rendered, and
drift findings carry periods. Each detector defines its payload
with its finding type; there is no shared envelope.
cities, states = [], []
for _ in range(1400):
cities.append("Chicago")
states.append("IL")
cities.append("Austin")
states.append("TX")
for i in range(200):
cities.append("Springfield")
states.append("IL" if i < 180 else "MO")
cities.append("Portland")
states.append("OR")
fd_report = onceover.profile(pl.DataFrame({"city": cities, "state": states}))
fd = next(f for f in fd_report.findings if "mapping" in f["evidence"][0]["metrics"])
print(fd["claim"])
for row in fd["evidence"][0]["metrics"]["mapping"]:
print(row)
city → state holds on 99.38% of rows (20 violations in 3200 rows)
{'key': 'Springfield', 'rows': 180, 'value': 'IL'}
{'key': 'Springfield', 'rows': 20, 'value': 'MO'}