fieldwork 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fieldwork/__init__.py +47 -0
- fieldwork/_explore/__init__.py +27 -0
- fieldwork/_explore/_kernels.py +64 -0
- fieldwork/_explore/census.py +479 -0
- fieldwork/_explore/encoding.py +264 -0
- fieldwork/_explore/grain.py +245 -0
- fieldwork/_explore/grain_graph.py +179 -0
- fieldwork/_explore/graphics.py +733 -0
- fieldwork/_explore/orchestration.py +166 -0
- fieldwork/_explore/relations.py +405 -0
- fieldwork/_explore/render.py +450 -0
- fieldwork/_explore/resolved.py +125 -0
- fieldwork/_explore/result.py +75 -0
- fieldwork/_explore/roles.py +103 -0
- fieldwork/_explore/visual_data.py +299 -0
- fieldwork/availability.py +367 -0
- fieldwork/discovery.py +251 -0
- fieldwork/evidence.py +483 -0
- fieldwork/families.py +73 -0
- fieldwork/navigation.py +360 -0
- fieldwork/patterns.py +189 -0
- fieldwork/presentation.py +530 -0
- fieldwork/py.typed +0 -0
- fieldwork/workflow.py +199 -0
- fieldwork-0.1.0.dist-info/METADATA +86 -0
- fieldwork-0.1.0.dist-info/RECORD +29 -0
- fieldwork-0.1.0.dist-info/WHEEL +4 -0
- fieldwork-0.1.0.dist-info/licenses/LICENSE +21 -0
- fieldwork-0.1.0.dist-info/licenses/NOTICE +6 -0
fieldwork/__init__.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Explore unfamiliar data through patterns and inspectable evidence."""
|
|
2
|
+
|
|
3
|
+
from ._explore import (
|
|
4
|
+
ExplorerResult,
|
|
5
|
+
KeySpec,
|
|
6
|
+
SchemaProposal,
|
|
7
|
+
census,
|
|
8
|
+
grain,
|
|
9
|
+
infer_schema,
|
|
10
|
+
joint_counts,
|
|
11
|
+
levels,
|
|
12
|
+
)
|
|
13
|
+
from ._explore.relations import pairs
|
|
14
|
+
from .availability import missingness
|
|
15
|
+
from .discovery import discover_dependencies
|
|
16
|
+
from .evidence import InvestigationResult, Scope
|
|
17
|
+
from .navigation import PathResult, suggest_paths
|
|
18
|
+
from .patterns import value_patterns
|
|
19
|
+
from .presentation import render_html, render_plaintext, render_svg, visualization_data
|
|
20
|
+
from .workflow import Recipe, compare, explore
|
|
21
|
+
|
|
22
|
+
__version__ = "0.1.0"
|
|
23
|
+
__all__ = [
|
|
24
|
+
"ExplorerResult",
|
|
25
|
+
"InvestigationResult",
|
|
26
|
+
"KeySpec",
|
|
27
|
+
"PathResult",
|
|
28
|
+
"Recipe",
|
|
29
|
+
"SchemaProposal",
|
|
30
|
+
"Scope",
|
|
31
|
+
"census",
|
|
32
|
+
"compare",
|
|
33
|
+
"discover_dependencies",
|
|
34
|
+
"explore",
|
|
35
|
+
"grain",
|
|
36
|
+
"infer_schema",
|
|
37
|
+
"joint_counts",
|
|
38
|
+
"levels",
|
|
39
|
+
"missingness",
|
|
40
|
+
"pairs",
|
|
41
|
+
"render_html",
|
|
42
|
+
"render_plaintext",
|
|
43
|
+
"render_svg",
|
|
44
|
+
"suggest_paths",
|
|
45
|
+
"value_patterns",
|
|
46
|
+
"visualization_data",
|
|
47
|
+
]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Feature hierarchy explorer public implementation exports."""
|
|
2
|
+
|
|
3
|
+
from .census import census, levels
|
|
4
|
+
from .grain import grain
|
|
5
|
+
from .graphics import render_html, render_svg
|
|
6
|
+
from .orchestration import explore
|
|
7
|
+
from .relations import joint_counts
|
|
8
|
+
from .render import render_plaintext
|
|
9
|
+
from .result import ExplorerResult, KeySpec
|
|
10
|
+
from .roles import SchemaProposal, infer_schema
|
|
11
|
+
from .visual_data import visualization_data
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"ExplorerResult",
|
|
15
|
+
"KeySpec",
|
|
16
|
+
"SchemaProposal",
|
|
17
|
+
"census",
|
|
18
|
+
"explore",
|
|
19
|
+
"grain",
|
|
20
|
+
"infer_schema",
|
|
21
|
+
"joint_counts",
|
|
22
|
+
"levels",
|
|
23
|
+
"render_html",
|
|
24
|
+
"render_plaintext",
|
|
25
|
+
"render_svg",
|
|
26
|
+
"visualization_data",
|
|
27
|
+
]
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Private exact grouping kernels used by the explorer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import Counter
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def dense_counts(codes: np.ndarray, rows: np.ndarray) -> dict[int, int]:
|
|
12
|
+
if rows.size == 0:
|
|
13
|
+
return {}
|
|
14
|
+
values, counts = np.unique(codes[rows], return_counts=True)
|
|
15
|
+
return {int(value): int(count) for value, count in zip(values, counts)}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def exact_pair_ids(
|
|
19
|
+
parents: np.ndarray, levels: np.ndarray, *, packing_limit: int | None = None
|
|
20
|
+
) -> tuple[np.ndarray, list[tuple[int, int]]]:
|
|
21
|
+
"""Dense IDs for exact integer pairs, with an overflow-safe tuple fallback."""
|
|
22
|
+
|
|
23
|
+
if parents.shape != levels.shape:
|
|
24
|
+
raise ValueError("parent and level arrays must have the same shape")
|
|
25
|
+
if parents.size == 0:
|
|
26
|
+
return np.empty(0, dtype=np.int64), []
|
|
27
|
+
width = int(levels.max()) + 1
|
|
28
|
+
max_parent = int(parents.max())
|
|
29
|
+
safe = np.iinfo(np.int64).max // max(width, 1)
|
|
30
|
+
if packing_limit is not None:
|
|
31
|
+
safe = min(safe, packing_limit)
|
|
32
|
+
if max_parent <= safe:
|
|
33
|
+
packed = parents.astype(np.int64) * width + levels.astype(np.int64)
|
|
34
|
+
unique, inverse = np.unique(packed, return_inverse=True)
|
|
35
|
+
pairs = [(int(item // width), int(item % width)) for item in unique]
|
|
36
|
+
return inverse.astype(np.int64, copy=False), pairs
|
|
37
|
+
lookup: dict[tuple[int, int], int] = {}
|
|
38
|
+
pair_list: list[tuple[int, int]] = []
|
|
39
|
+
inverse = np.empty(len(parents), dtype=np.int64)
|
|
40
|
+
for index, pair in enumerate(zip(parents.tolist(), levels.tolist())):
|
|
41
|
+
normalized = (int(pair[0]), int(pair[1]))
|
|
42
|
+
dense = lookup.get(normalized)
|
|
43
|
+
if dense is None:
|
|
44
|
+
dense = len(pair_list)
|
|
45
|
+
lookup[normalized] = dense
|
|
46
|
+
pair_list.append(normalized)
|
|
47
|
+
inverse[index] = dense
|
|
48
|
+
ordered = sorted(pair_list)
|
|
49
|
+
remap = np.empty(len(pair_list), dtype=np.int64)
|
|
50
|
+
for new_id, pair in enumerate(ordered):
|
|
51
|
+
remap[lookup[pair]] = new_id
|
|
52
|
+
return remap[inverse], ordered
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def python_prefix_counts(rows: Iterable[tuple[object, ...]]) -> list[Counter]:
|
|
56
|
+
"""Readable reference kernel used by differential tests."""
|
|
57
|
+
|
|
58
|
+
counters: list[Counter] = []
|
|
59
|
+
for row in rows:
|
|
60
|
+
for depth in range(1, len(row) + 1):
|
|
61
|
+
if len(counters) < depth:
|
|
62
|
+
counters.append(Counter())
|
|
63
|
+
counters[depth - 1][row[:depth]] += 1
|
|
64
|
+
return counters
|
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
"""Independent level counts and bounded nested census."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import deque
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
import pandas as pd
|
|
11
|
+
|
|
12
|
+
from ._kernels import dense_counts
|
|
13
|
+
from .encoding import (
|
|
14
|
+
MISSING,
|
|
15
|
+
ScalarIdentity,
|
|
16
|
+
encode_series,
|
|
17
|
+
normalize_scalar,
|
|
18
|
+
resolve_columns,
|
|
19
|
+
validate_limit,
|
|
20
|
+
validate_schema,
|
|
21
|
+
)
|
|
22
|
+
from .result import ExplorerResult
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _source(df: pd.DataFrame) -> dict[str, Any]:
|
|
26
|
+
return {
|
|
27
|
+
"table_id": "table",
|
|
28
|
+
"rows": len(df),
|
|
29
|
+
"columns": len(df.columns),
|
|
30
|
+
"dtypes": [
|
|
31
|
+
{
|
|
32
|
+
"column": normalize_scalar(column, label=True).to_dict(),
|
|
33
|
+
"dtype": str(df[column].dtype),
|
|
34
|
+
}
|
|
35
|
+
for column in df.columns
|
|
36
|
+
],
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _scope(
|
|
41
|
+
scope_id: str,
|
|
42
|
+
input_rows: int,
|
|
43
|
+
missing_excluded_rows: int,
|
|
44
|
+
restriction_excluded_rows: int,
|
|
45
|
+
conditional: bool,
|
|
46
|
+
lineage: list[str] | None = None,
|
|
47
|
+
*,
|
|
48
|
+
parent_scope: dict[str, Any] | None = None,
|
|
49
|
+
) -> dict[str, Any]:
|
|
50
|
+
if parent_scope is not None:
|
|
51
|
+
input_rows = parent_scope["input_rows"]
|
|
52
|
+
missing_excluded_rows += parent_scope["missing_excluded_rows"]
|
|
53
|
+
restriction_excluded_rows += parent_scope["restriction_excluded_rows"]
|
|
54
|
+
conditional = conditional or parent_scope["conditional"]
|
|
55
|
+
lineage = [*parent_scope["lineage"], parent_scope["scope_id"], *(lineage or [])]
|
|
56
|
+
evaluated = input_rows - missing_excluded_rows - restriction_excluded_rows
|
|
57
|
+
return {
|
|
58
|
+
"scope_id": scope_id,
|
|
59
|
+
"input_rows": input_rows,
|
|
60
|
+
"missing_excluded_rows": missing_excluded_rows,
|
|
61
|
+
"restriction_excluded_rows": restriction_excluded_rows,
|
|
62
|
+
"evaluated_rows": evaluated,
|
|
63
|
+
"retained_rows": evaluated,
|
|
64
|
+
"conditional": conditional,
|
|
65
|
+
"lineage": lineage or [],
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _rank_counts(counts: dict[int, int], values: list[ScalarIdentity]) -> list[tuple[int, int]]:
|
|
70
|
+
return sorted(counts.items(), key=lambda item: (-item[1], values[item[0]].sort_key()))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _mixed_warning(
|
|
74
|
+
feature_id: str, column: Any, values: Iterable[ScalarIdentity]
|
|
75
|
+
) -> dict[str, Any] | None:
|
|
76
|
+
families = sorted({value.kind for value in values if value is not MISSING})
|
|
77
|
+
if len(families) > 1:
|
|
78
|
+
return {
|
|
79
|
+
"code": "MIXED_LEVEL_TYPES",
|
|
80
|
+
"feature_id": feature_id,
|
|
81
|
+
"column": normalize_scalar(column, label=True).to_dict(),
|
|
82
|
+
"families": families,
|
|
83
|
+
}
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def levels(
|
|
88
|
+
df: pd.DataFrame,
|
|
89
|
+
features: Iterable[Any] | None = None,
|
|
90
|
+
*,
|
|
91
|
+
top_n: int | None = None,
|
|
92
|
+
max_levels: int | None = 100,
|
|
93
|
+
min_count: int = 1,
|
|
94
|
+
dropna: bool = False,
|
|
95
|
+
schema: dict[Any, str] | None = None,
|
|
96
|
+
engine_metadata: bool = False,
|
|
97
|
+
scope_metadata: dict[str, Any] | None = None,
|
|
98
|
+
) -> ExplorerResult:
|
|
99
|
+
"""Count levels independently for every requested feature."""
|
|
100
|
+
|
|
101
|
+
selected = resolve_columns(df, features, argument="features", default_all=True)
|
|
102
|
+
validate_schema(df, schema)
|
|
103
|
+
validate_limit("top_n", top_n, zero=False)
|
|
104
|
+
validate_limit("max_levels", max_levels)
|
|
105
|
+
validate_limit("min_count", min_count)
|
|
106
|
+
records: list[dict[str, Any]] = []
|
|
107
|
+
warnings: list[dict[str, Any]] = []
|
|
108
|
+
scopes: list[dict[str, Any]] = []
|
|
109
|
+
for position, column in enumerate(selected):
|
|
110
|
+
feature_id = f"f{position}"
|
|
111
|
+
values, codes = encode_series(df[column])
|
|
112
|
+
missing_code = values.index(MISSING) if MISSING in values else None
|
|
113
|
+
eligible = np.arange(len(df), dtype=np.int64)
|
|
114
|
+
missing_excluded = 0
|
|
115
|
+
if dropna and missing_code is not None:
|
|
116
|
+
keep = codes != missing_code
|
|
117
|
+
missing_excluded = int((~keep).sum())
|
|
118
|
+
eligible = eligible[keep]
|
|
119
|
+
counts = dense_counts(codes, eligible)
|
|
120
|
+
ranked = _rank_counts(counts, values)
|
|
121
|
+
semantic = ranked[:top_n] if top_n is not None else ranked
|
|
122
|
+
semantic = [item for item in semantic if item[1] >= min_count]
|
|
123
|
+
reported = semantic[:max_levels] if max_levels is not None else semantic
|
|
124
|
+
evaluated_rows = len(eligible)
|
|
125
|
+
output_levels = [
|
|
126
|
+
{
|
|
127
|
+
"level_id": f"{feature_id}:l{code}",
|
|
128
|
+
"rank": rank,
|
|
129
|
+
"value": values[code].to_dict(),
|
|
130
|
+
"count": count,
|
|
131
|
+
"share_of_feature": count / evaluated_rows if evaluated_rows else None,
|
|
132
|
+
"share_reason": None if evaluated_rows else "empty_population",
|
|
133
|
+
}
|
|
134
|
+
for rank, (code, count) in enumerate(reported, 1)
|
|
135
|
+
]
|
|
136
|
+
reported_rows = sum(item[1] for item in reported)
|
|
137
|
+
scope_id = f"s1:{feature_id}"
|
|
138
|
+
scopes.append(_scope(scope_id, len(df), missing_excluded, 0, False))
|
|
139
|
+
records.append(
|
|
140
|
+
{
|
|
141
|
+
"feature_id": feature_id,
|
|
142
|
+
"column": normalize_scalar(column, label=True).to_dict(),
|
|
143
|
+
"role": (schema or {}).get(column),
|
|
144
|
+
"scope_id": scope_id,
|
|
145
|
+
"status": "empty" if evaluated_rows == 0 else "computed",
|
|
146
|
+
"levels_total": len(ranked),
|
|
147
|
+
"levels_reported": len(reported),
|
|
148
|
+
"omitted_levels": len(ranked) - len(reported),
|
|
149
|
+
"reported_rows": reported_rows,
|
|
150
|
+
"unreported_rows": evaluated_rows - reported_rows,
|
|
151
|
+
"levels": output_levels,
|
|
152
|
+
}
|
|
153
|
+
)
|
|
154
|
+
warning = _mixed_warning(feature_id, column, values)
|
|
155
|
+
if warning:
|
|
156
|
+
warnings.append(warning)
|
|
157
|
+
role = (schema or {}).get(column)
|
|
158
|
+
if role in {"id", "continuous"}:
|
|
159
|
+
warnings.append(
|
|
160
|
+
{
|
|
161
|
+
"code": "EXPLICIT_ROLE_SELECTION",
|
|
162
|
+
"feature_id": feature_id,
|
|
163
|
+
"column": normalize_scalar(column, label=True).to_dict(),
|
|
164
|
+
"role": role,
|
|
165
|
+
}
|
|
166
|
+
)
|
|
167
|
+
payload: dict[str, Any] = {
|
|
168
|
+
"status": "empty" if len(df) == 0 else "computed",
|
|
169
|
+
"source": _source(df),
|
|
170
|
+
"scopes": scopes,
|
|
171
|
+
"effective_limits": {
|
|
172
|
+
"top_n": top_n,
|
|
173
|
+
"max_levels": max_levels,
|
|
174
|
+
"min_count": min_count,
|
|
175
|
+
"dropna": dropna,
|
|
176
|
+
},
|
|
177
|
+
"per_feature": records,
|
|
178
|
+
"warnings": warnings,
|
|
179
|
+
}
|
|
180
|
+
if scope_metadata:
|
|
181
|
+
payload["scope_metadata"] = scope_metadata
|
|
182
|
+
if engine_metadata:
|
|
183
|
+
payload["engine"] = {"name": "typed_dense_counts"}
|
|
184
|
+
return ExplorerResult("levels", payload)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _pre_mask_per_parent(
|
|
188
|
+
codes: list[np.ndarray],
|
|
189
|
+
values: list[list[ScalarIdentity]],
|
|
190
|
+
eligible: np.ndarray,
|
|
191
|
+
top_n: int,
|
|
192
|
+
) -> tuple[np.ndarray, list[dict[str, Any]]]:
|
|
193
|
+
surviving = np.zeros(codes[0].shape[0], dtype=bool)
|
|
194
|
+
retained: list[dict[str, Any]] = []
|
|
195
|
+
queue: deque[tuple[int, np.ndarray, tuple[int, ...]]] = deque([(0, eligible, ())])
|
|
196
|
+
while queue:
|
|
197
|
+
depth, rows, path = queue.popleft()
|
|
198
|
+
counts = dense_counts(codes[depth], rows)
|
|
199
|
+
chosen = _rank_counts(counts, values[depth])[:top_n]
|
|
200
|
+
retained.append(
|
|
201
|
+
{
|
|
202
|
+
"path": list(path),
|
|
203
|
+
"depth": depth + 1,
|
|
204
|
+
"level_codes": [code for code, _ in chosen],
|
|
205
|
+
}
|
|
206
|
+
)
|
|
207
|
+
for code, _ in chosen:
|
|
208
|
+
child_rows = rows[codes[depth][rows] == code]
|
|
209
|
+
child_path = (*path, code)
|
|
210
|
+
if depth + 1 == len(codes):
|
|
211
|
+
surviving[child_rows] = True
|
|
212
|
+
else:
|
|
213
|
+
queue.append((depth + 1, child_rows, child_path))
|
|
214
|
+
return surviving, retained
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _census(
|
|
218
|
+
df: pd.DataFrame,
|
|
219
|
+
dimensions: Iterable[Any],
|
|
220
|
+
*,
|
|
221
|
+
top_n: int | None = None,
|
|
222
|
+
top_n_mode: str = "post",
|
|
223
|
+
top_n_per_parent: bool = False,
|
|
224
|
+
min_retained_fraction: float = 0.01,
|
|
225
|
+
max_depth: int | None = None,
|
|
226
|
+
max_levels: int | None = 100,
|
|
227
|
+
max_nodes: int | None = 10000,
|
|
228
|
+
min_count: int = 1,
|
|
229
|
+
dropna: bool = False,
|
|
230
|
+
schema: dict[Any, str] | None = None,
|
|
231
|
+
engine_metadata: bool = False,
|
|
232
|
+
) -> ExplorerResult:
|
|
233
|
+
"""Build a deterministic, ancestor-closed observed-prefix census."""
|
|
234
|
+
|
|
235
|
+
selected = resolve_columns(df, dimensions, argument="dimensions")
|
|
236
|
+
validate_schema(df, schema)
|
|
237
|
+
validate_limit("top_n", top_n, zero=False)
|
|
238
|
+
validate_limit("max_depth", max_depth, zero=False)
|
|
239
|
+
validate_limit("max_levels", max_levels)
|
|
240
|
+
validate_limit("max_nodes", max_nodes)
|
|
241
|
+
validate_limit("min_count", min_count)
|
|
242
|
+
if top_n_mode not in {"pre", "post"}:
|
|
243
|
+
raise ValueError("top_n_mode must be 'pre' or 'post'")
|
|
244
|
+
if (
|
|
245
|
+
not isinstance(min_retained_fraction, (int, float))
|
|
246
|
+
or isinstance(min_retained_fraction, bool)
|
|
247
|
+
or not 0 <= min_retained_fraction <= 1
|
|
248
|
+
):
|
|
249
|
+
raise ValueError("min_retained_fraction must be between 0 and 1")
|
|
250
|
+
active = selected[:max_depth] if max_depth is not None else selected
|
|
251
|
+
dictionaries: list[list[ScalarIdentity]] = []
|
|
252
|
+
code_arrays: list[np.ndarray] = []
|
|
253
|
+
missing_codes: list[int | None] = []
|
|
254
|
+
for column in active:
|
|
255
|
+
values, codes = encode_series(df[column])
|
|
256
|
+
dictionaries.append(values)
|
|
257
|
+
code_arrays.append(codes)
|
|
258
|
+
missing_codes.append(values.index(MISSING) if MISSING in values else None)
|
|
259
|
+
eligible_mask = np.ones(len(df), dtype=bool)
|
|
260
|
+
if dropna:
|
|
261
|
+
for codes, missing_code in zip(code_arrays, missing_codes):
|
|
262
|
+
if missing_code is not None:
|
|
263
|
+
eligible_mask &= codes != missing_code
|
|
264
|
+
eligible = np.flatnonzero(eligible_mask)
|
|
265
|
+
missing_excluded = len(df) - len(eligible)
|
|
266
|
+
retained_metadata: list[dict[str, Any]] = []
|
|
267
|
+
evaluated = eligible
|
|
268
|
+
warnings: list[dict[str, Any]] = []
|
|
269
|
+
if top_n_mode == "pre" and top_n is not None and len(eligible):
|
|
270
|
+
if top_n_per_parent:
|
|
271
|
+
final_mask, retained_metadata = _pre_mask_per_parent(
|
|
272
|
+
code_arrays, dictionaries, eligible, top_n
|
|
273
|
+
)
|
|
274
|
+
else:
|
|
275
|
+
final_mask = eligible_mask.copy()
|
|
276
|
+
for depth, (codes, values) in enumerate(zip(code_arrays, dictionaries)):
|
|
277
|
+
ranked = _rank_counts(dense_counts(codes, eligible), values)
|
|
278
|
+
chosen = {code for code, _ in ranked[:top_n]}
|
|
279
|
+
retained_metadata.append({"depth": depth + 1, "level_codes": sorted(chosen)})
|
|
280
|
+
final_mask &= np.isin(codes, list(chosen))
|
|
281
|
+
evaluated = np.flatnonzero(final_mask)
|
|
282
|
+
if not len(evaluated):
|
|
283
|
+
raise ValueError(
|
|
284
|
+
"DEGENERATE_TOP_N: pre selection removed every eligible row "
|
|
285
|
+
f"({len(eligible)} eligible rows)"
|
|
286
|
+
)
|
|
287
|
+
fraction = len(evaluated) / len(eligible)
|
|
288
|
+
if fraction < min_retained_fraction:
|
|
289
|
+
warnings.append(
|
|
290
|
+
{
|
|
291
|
+
"code": "LOW_RETAINED_FRACTION",
|
|
292
|
+
"retained_fraction": fraction,
|
|
293
|
+
"eligible_rows": len(eligible),
|
|
294
|
+
"retained_rows": len(evaluated),
|
|
295
|
+
}
|
|
296
|
+
)
|
|
297
|
+
restriction_excluded = len(eligible) - len(evaluated)
|
|
298
|
+
scope = _scope(
|
|
299
|
+
"s2",
|
|
300
|
+
len(df),
|
|
301
|
+
missing_excluded,
|
|
302
|
+
restriction_excluded,
|
|
303
|
+
top_n_mode == "pre" and restriction_excluded > 0,
|
|
304
|
+
["input", "dropna" if dropna else "include_missing", top_n_mode],
|
|
305
|
+
)
|
|
306
|
+
features = [
|
|
307
|
+
{
|
|
308
|
+
"feature_id": f"f{index}",
|
|
309
|
+
"column": normalize_scalar(column, label=True).to_dict(),
|
|
310
|
+
"role": (schema or {}).get(column),
|
|
311
|
+
"dictionary_cardinality": len(dictionaries[index]),
|
|
312
|
+
}
|
|
313
|
+
for index, column in enumerate(active)
|
|
314
|
+
]
|
|
315
|
+
for index, values in enumerate(dictionaries):
|
|
316
|
+
warning = _mixed_warning(f"f{index}", active[index], values)
|
|
317
|
+
if warning:
|
|
318
|
+
warnings.append(warning)
|
|
319
|
+
role = (schema or {}).get(active[index])
|
|
320
|
+
if role in {"id", "continuous"}:
|
|
321
|
+
warnings.append(
|
|
322
|
+
{
|
|
323
|
+
"code": "EXPLICIT_ROLE_SELECTION",
|
|
324
|
+
"feature_id": f"f{index}",
|
|
325
|
+
"column": normalize_scalar(active[index], label=True).to_dict(),
|
|
326
|
+
"role": role,
|
|
327
|
+
}
|
|
328
|
+
)
|
|
329
|
+
global_chosen: list[set[int] | None] = []
|
|
330
|
+
for codes, values in zip(code_arrays, dictionaries):
|
|
331
|
+
if top_n is not None and not top_n_per_parent and top_n_mode == "post":
|
|
332
|
+
global_chosen.append(
|
|
333
|
+
{code for code, _ in _rank_counts(dense_counts(codes, evaluated), values)[:top_n]}
|
|
334
|
+
)
|
|
335
|
+
else:
|
|
336
|
+
global_chosen.append(None)
|
|
337
|
+
nodes: list[dict[str, Any]] = []
|
|
338
|
+
emitted_levels: set[tuple[int, int]] = set()
|
|
339
|
+
queue: deque[tuple[str, int, np.ndarray, int]] = deque()
|
|
340
|
+
queue.append(("root", 0, evaluated, len(evaluated)))
|
|
341
|
+
root = {
|
|
342
|
+
"node_id": "root",
|
|
343
|
+
"parent_id": None,
|
|
344
|
+
"feature_id": None,
|
|
345
|
+
"level_id": None,
|
|
346
|
+
"depth": 0,
|
|
347
|
+
"count": len(evaluated),
|
|
348
|
+
"share_of_parent": None,
|
|
349
|
+
"share_of_total": 1.0 if len(evaluated) else None,
|
|
350
|
+
"share_reason": None if len(evaluated) else "empty_population",
|
|
351
|
+
"expansion_state": "unexpanded" if active else "complete",
|
|
352
|
+
"omitted_child_rows": 0,
|
|
353
|
+
"omitted_child_levels": 0,
|
|
354
|
+
"stop_reasons": [],
|
|
355
|
+
}
|
|
356
|
+
node_lookup: dict[str, dict[str, Any]] = {"root": root}
|
|
357
|
+
next_id = 0
|
|
358
|
+
while queue:
|
|
359
|
+
parent_id, depth, rows, parent_count = queue.popleft()
|
|
360
|
+
parent = node_lookup[parent_id]
|
|
361
|
+
if depth >= len(active):
|
|
362
|
+
parent["expansion_state"] = "complete"
|
|
363
|
+
continue
|
|
364
|
+
counts = dense_counts(code_arrays[depth], rows)
|
|
365
|
+
ranked = _rank_counts(counts, dictionaries[depth])
|
|
366
|
+
chosen = ranked
|
|
367
|
+
if top_n is not None:
|
|
368
|
+
if top_n_per_parent and top_n_mode == "post":
|
|
369
|
+
chosen = chosen[:top_n]
|
|
370
|
+
elif global_chosen[depth] is not None:
|
|
371
|
+
chosen = [item for item in chosen if item[0] in global_chosen[depth]]
|
|
372
|
+
chosen = [item for item in chosen if item[1] >= min_count]
|
|
373
|
+
if max_levels is not None:
|
|
374
|
+
chosen = chosen[:max_levels]
|
|
375
|
+
remaining_budget = None if max_nodes is None else max_nodes - len(nodes)
|
|
376
|
+
budget_truncated = remaining_budget is not None and len(chosen) > max(0, remaining_budget)
|
|
377
|
+
if remaining_budget is not None:
|
|
378
|
+
chosen = chosen[: max(0, remaining_budget)]
|
|
379
|
+
chosen_codes = {code for code, _ in chosen}
|
|
380
|
+
omitted_rows = sum(count for code, count in ranked if code not in chosen_codes)
|
|
381
|
+
parent["omitted_child_rows"] = omitted_rows
|
|
382
|
+
parent["omitted_child_levels"] = len(ranked) - len(chosen)
|
|
383
|
+
parent["expansion_state"] = "expanded"
|
|
384
|
+
reasons: list[str] = []
|
|
385
|
+
if len(chosen) < len(ranked):
|
|
386
|
+
if top_n is not None:
|
|
387
|
+
reasons.append("top_n")
|
|
388
|
+
if max_levels is not None and len(ranked) > max_levels:
|
|
389
|
+
reasons.append("max_levels")
|
|
390
|
+
if budget_truncated or (max_nodes is not None and len(nodes) >= max_nodes):
|
|
391
|
+
reasons.append("max_nodes")
|
|
392
|
+
if any(count < min_count for _, count in ranked):
|
|
393
|
+
reasons.append("min_count")
|
|
394
|
+
parent["stop_reasons"] = sorted(set(reasons))
|
|
395
|
+
for code, count in chosen:
|
|
396
|
+
node_id = f"n{next_id}"
|
|
397
|
+
next_id += 1
|
|
398
|
+
child_rows = rows[code_arrays[depth][rows] == code]
|
|
399
|
+
node = {
|
|
400
|
+
"node_id": node_id,
|
|
401
|
+
"parent_id": parent_id,
|
|
402
|
+
"feature_id": f"f{depth}",
|
|
403
|
+
"level_id": f"f{depth}:l{code}",
|
|
404
|
+
"depth": depth + 1,
|
|
405
|
+
"count": count,
|
|
406
|
+
"share_of_parent": count / parent_count if parent_count else None,
|
|
407
|
+
"share_of_total": count / len(evaluated) if len(evaluated) else None,
|
|
408
|
+
"share_reason": None if len(evaluated) else "empty_population",
|
|
409
|
+
"expansion_state": "complete" if depth + 1 == len(active) else "unexpanded",
|
|
410
|
+
"omitted_child_rows": 0,
|
|
411
|
+
"omitted_child_levels": 0,
|
|
412
|
+
"stop_reasons": [],
|
|
413
|
+
}
|
|
414
|
+
nodes.append(node)
|
|
415
|
+
node_lookup[node_id] = node
|
|
416
|
+
emitted_levels.add((depth, code))
|
|
417
|
+
if depth + 1 < len(active):
|
|
418
|
+
queue.append((node_id, depth + 1, child_rows, count))
|
|
419
|
+
# Pre-selection metadata must remain decodable even when no corresponding
|
|
420
|
+
# tree node survives the output budgets or the conjunctive pre filter.
|
|
421
|
+
referenced_levels = emitted_levels.copy()
|
|
422
|
+
for retained in retained_metadata:
|
|
423
|
+
referenced_levels.update((retained["depth"] - 1, code) for code in retained["level_codes"])
|
|
424
|
+
referenced_levels.update(enumerate(retained.get("path", [])))
|
|
425
|
+
level_dictionary = [
|
|
426
|
+
{
|
|
427
|
+
"level_id": f"f{depth}:l{code}",
|
|
428
|
+
"feature_id": f"f{depth}",
|
|
429
|
+
"value": dictionaries[depth][code].to_dict(),
|
|
430
|
+
}
|
|
431
|
+
for depth, code in sorted(
|
|
432
|
+
referenced_levels,
|
|
433
|
+
key=lambda item: (item[0], dictionaries[item[0]][item[1]].sort_key()),
|
|
434
|
+
)
|
|
435
|
+
]
|
|
436
|
+
status = "empty" if not len(evaluated) else "computed"
|
|
437
|
+
if any(node["omitted_child_rows"] for node in [root, *nodes]):
|
|
438
|
+
status = "partial" if len(evaluated) else status
|
|
439
|
+
payload: dict[str, Any] = {
|
|
440
|
+
"status": status,
|
|
441
|
+
"source": _source(df),
|
|
442
|
+
"scopes": [scope],
|
|
443
|
+
"features": features,
|
|
444
|
+
"level_dictionary": level_dictionary,
|
|
445
|
+
"tree": {
|
|
446
|
+
"status": status,
|
|
447
|
+
"scope_id": "s2",
|
|
448
|
+
"dimensions": [f"f{i}" for i in range(len(active))],
|
|
449
|
+
"requested_depth": len(active),
|
|
450
|
+
"root": root,
|
|
451
|
+
"nodes": nodes,
|
|
452
|
+
"retained_sets": retained_metadata,
|
|
453
|
+
},
|
|
454
|
+
"effective_limits": {
|
|
455
|
+
"top_n": top_n,
|
|
456
|
+
"top_n_mode": top_n_mode,
|
|
457
|
+
"top_n_per_parent": top_n_per_parent,
|
|
458
|
+
"max_depth": max_depth,
|
|
459
|
+
"max_levels": max_levels,
|
|
460
|
+
"max_nodes": max_nodes,
|
|
461
|
+
"min_count": min_count,
|
|
462
|
+
"dropna": dropna,
|
|
463
|
+
},
|
|
464
|
+
"warnings": warnings,
|
|
465
|
+
}
|
|
466
|
+
if engine_metadata:
|
|
467
|
+
payload["engine"] = {"name": "encoded_observed_prefix_refinement"}
|
|
468
|
+
return ExplorerResult("census", payload)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def census(df, dimensions, *, scope=None, missing=None, table_id="table", **options):
|
|
472
|
+
"""Build an observed-prefix census, optionally preserving a discovery scope and sentinels."""
|
|
473
|
+
if scope is None and missing is None and table_id == "table":
|
|
474
|
+
return _census(df, dimensions, **options)
|
|
475
|
+
from ..evidence import foundation_context
|
|
476
|
+
|
|
477
|
+
return foundation_context(
|
|
478
|
+
df, _census, dimensions, scope=scope, missing=missing, table_id=table_id, **options
|
|
479
|
+
)
|