audit-labs/audit-tools
A collection of scripts, queries, and other goodies you can use in an audit.
clone: git clone https://gitbay.org/audit-labs/audit-tools.git
v1.0.0: sampling/sampling_tool/validation.py · raw
1"""Validation and population preparation."""
2
3from __future__ import annotations
4
5from dataclasses import dataclass
6
7import pandas as pd
8
9from .io import AuditSamplingError
10from .methods import parse_key_floats, parse_key_ints
11
12
13@dataclass
14class ValidationResult:
15 population: pd.DataFrame
16 excluded_rows: pd.DataFrame
17 duplicate_rows: pd.DataFrame
18 effective_id_column: str
19 id_column_omitted: bool
20 blank_id_count: int
21 duplicate_id_count: int
22 warnings: list[str]
23 strata_counts: dict[str, int]
24 strata_proportions: dict[str, float]
25
26
27def blank_id_mask(series: pd.Series) -> pd.Series:
28 return series.isna() | (series.astype("string").fillna("").str.strip() == "")
29
30
31def validate_and_prepare(population: pd.DataFrame, options) -> ValidationResult:
32 warnings: list[str] = []
33 excluded_parts: list[pd.DataFrame] = []
34 working = population.copy()
35
36 if options.id_column:
37 if options.id_column not in working.columns:
38 raise AuditSamplingError(f"ID column not found: {options.id_column}")
39 effective_id_column = options.id_column
40 id_column_omitted = False
41 else:
42 effective_id_column = "_audit_row_id"
43 id_column_omitted = True
44 working[effective_id_column] = working["_source_row_number"]
45 warnings.append(
46 "--id-column omitted; using _source_row_number as generated _audit_row_id."
47 )
48
49 if options.stratify_column and options.stratify_column not in working.columns:
50 raise AuditSamplingError(
51 f"Stratification column not found: {options.stratify_column}"
52 )
53
54 id_blank_mask = blank_id_mask(working[effective_id_column])
55 blank_id_count = int(id_blank_mask.sum())
56 if blank_id_count:
57 if options.exclude_blank_id:
58 excluded = working.loc[id_blank_mask].copy()
59 excluded["_exclusion_reason"] = "Blank ID"
60 excluded_parts.append(excluded)
61 working = working.loc[~id_blank_mask].copy()
62 else:
63 warnings.append(
64 f"{blank_id_count} row(s) have blank IDs and were retained."
65 )
66
67 nonblank_ids = ~blank_id_mask(working[effective_id_column])
68 duplicate_mask = working.loc[nonblank_ids, effective_id_column].duplicated(
69 keep=False
70 )
71 duplicate_rows = working.loc[nonblank_ids].loc[duplicate_mask].copy()
72 duplicate_id_count = len(duplicate_rows)
73 if duplicate_id_count:
74 if options.dedupe_id == "fail":
75 raise AuditSamplingError(
76 f"Duplicate IDs found in '{effective_id_column}'. "
77 "See duplicate_ids.csv.",
78 duplicate_rows=duplicate_rows,
79 )
80 keep = "first" if options.dedupe_id == "first" else "last"
81 drop_mask = working[effective_id_column].duplicated(keep=keep) & ~blank_id_mask(
82 working[effective_id_column]
83 )
84 excluded = working.loc[drop_mask].copy()
85 excluded["_exclusion_reason"] = f"Duplicate ID removed by dedupe={keep}"
86 excluded_parts.append(excluded)
87 working = working.loc[~drop_mask].copy()
88 warnings.append(
89 f"{len(excluded)} duplicate ID row(s) removed using dedupe={keep}."
90 )
91
92 if options.sample_size is not None and options.sample_size <= 0:
93 raise AuditSamplingError("--sample-size must be a positive integer.")
94 needs_sample_size = options.method == "random" or (
95 options.method == "stratified" and bool(options.strata_proportions)
96 )
97 if needs_sample_size:
98 if options.sample_size is None:
99 raise AuditSamplingError(f"--sample-size is required for {options.method}.")
100 if options.sample_size > len(working) and not (
101 options.method == "stratified" and options.allow_shortfall
102 ):
103 raise AuditSamplingError(
104 "--sample-size cannot exceed the validated population size."
105 )
106
107 strata_counts: dict[str, int] = {}
108 strata_proportions: dict[str, float] = {}
109 if options.method == "stratified":
110 if not options.stratify_column:
111 raise AuditSamplingError("--stratify-column is required for stratified.")
112 has_counts = bool(options.strata_counts)
113 has_proportions = bool(options.strata_proportions)
114 if has_counts == has_proportions:
115 raise AuditSamplingError(
116 "Use exactly one of --strata-counts or --strata-proportions."
117 )
118 strata_counts = parse_key_ints(options.strata_counts, "strata")
119 strata_proportions = parse_key_floats(options.strata_proportions, "strata")
120 requested_strata = set(strata_counts or strata_proportions)
121 actual_strata = set(working[options.stratify_column].dropna().astype(str))
122 missing = sorted(requested_strata - actual_strata)
123 if missing:
124 raise AuditSamplingError(
125 "Requested strata not found in population: " + ", ".join(missing)
126 )
127
128 if strata_counts:
129 _validate_stratum_counts_fit(working, options, strata_counts)
130
131 if excluded_parts:
132 excluded_rows = pd.concat(excluded_parts, ignore_index=True)
133 else:
134 excluded_rows = working.iloc[0:0].copy()
135
136 return ValidationResult(
137 population=working,
138 excluded_rows=excluded_rows,
139 duplicate_rows=duplicate_rows,
140 effective_id_column=effective_id_column,
141 id_column_omitted=id_column_omitted,
142 blank_id_count=blank_id_count,
143 duplicate_id_count=duplicate_id_count,
144 warnings=warnings,
145 strata_counts=strata_counts,
146 strata_proportions=strata_proportions,
147 )
148
149
150def _validate_stratum_counts_fit(population, options, counts: dict[str, int]) -> None:
151 for stratum, requested in counts.items():
152 available = int((population[options.stratify_column] == stratum).sum())
153 if requested > available and not options.allow_shortfall:
154 raise AuditSamplingError(
155 f"Stratum '{stratum}' has {available} rows; requested {requested}. "
156 "Use --allow-shortfall to continue."
157 )