signapy 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.
signapy/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ """SignaPy: exploratory feature discovery for labeled datasets.
2
+
3
+ SignaPy is pre-1.0, so the API may still change between minor releases.
4
+ ``signapy.discover`` currently supports a binary target
5
+ against mixed categorical, continuous, and ordinal features. See
6
+ ``docs/architecture.md`` for the design contract and roadmap, and
7
+ ``docs/metrics.md`` for how to interpret the evidence it returns.
8
+ """
9
+
10
+ from signapy.discovery import discover
11
+
12
+ __version__ = "0.1.0"
13
+
14
+ __all__ = ["__version__", "discover"]
@@ -0,0 +1,378 @@
1
+ """Discovery workflows: the user-facing layer of SignaPy.
2
+
3
+ This package answers discovery *questions* rather than exposing individual
4
+ statistical tests. It chooses an appropriate method from the feature and
5
+ target types, calls into :mod:`signapy.metrics`, and packages the evidence
6
+ into :mod:`signapy.results` models.
7
+
8
+ Modules:
9
+
10
+ - ``feature``: feature-level discovery for one categorical feature
11
+ (:func:`~signapy.discovery.feature.analyze_categorical_feature`), one
12
+ continuous feature
13
+ (:func:`~signapy.discovery.feature.analyze_continuous_feature`), or one
14
+ ordinal feature
15
+ (:func:`~signapy.discovery.feature.analyze_ordinal_feature`)
16
+ - ``value``: value-level discovery, adapting
17
+ :func:`~signapy.metrics.lift.categorical_lift` to
18
+ :class:`~signapy.results.ValueResult`
19
+ (:func:`~signapy.discovery.value.build_value_results`). Reused by both
20
+ the categorical and ordinal analyzers (ordinal reorders the result to
21
+ its declared category order); for continuous features
22
+ the feature is first cut into adaptive continuous bins
23
+ (:func:`~signapy.discovery.value.build_binned_value_results`) and each bin
24
+ is a value (see ``docs/architecture.md``).
25
+
26
+ This module's :func:`discover` is the top-level entry point, exported as
27
+ ``signapy.discover``. It supports categorical, continuous, and ordinal
28
+ features against a binary target; :data:`_DISPATCH` is where support for
29
+ additional ``(FeatureType, TargetType)`` combinations will be added.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ from collections.abc import Callable, Hashable, Mapping
35
+ from typing import TYPE_CHECKING
36
+
37
+ import pandas as pd
38
+
39
+ from signapy.discovery._frame import (
40
+ is_polars_dataframe,
41
+ polars_to_pandas,
42
+ )
43
+ from signapy.discovery.feature import (
44
+ analyze_categorical_feature,
45
+ analyze_continuous_feature,
46
+ analyze_ordinal_feature,
47
+ )
48
+ from signapy.profiling import FeatureType, TargetType
49
+ from signapy.results import DiscoveryReport, FeatureResult
50
+
51
+ __all__ = ["discover"]
52
+
53
+ if TYPE_CHECKING:
54
+ import polars as pl
55
+
56
+ _FeatureAnalyzer = Callable[..., FeatureResult]
57
+
58
+ # Method dispatch: which analyzer handles a given (declared feature type,
59
+ # target type) combination. Adding a new supported combination (e.g. a
60
+ # continuous target, or boolean features) means adding an entry here and to
61
+ # _SUPPORTED_FEATURE_TYPES/_resolve_feature_type, not a longer if/elif chain.
62
+ _DISPATCH: dict[tuple[FeatureType, TargetType], _FeatureAnalyzer] = {
63
+ (FeatureType.CATEGORICAL, TargetType.BINARY): analyze_categorical_feature,
64
+ (FeatureType.CONTINUOUS, TargetType.BINARY): analyze_continuous_feature,
65
+ (FeatureType.ORDINAL, TargetType.BINARY): analyze_ordinal_feature,
66
+ }
67
+
68
+ # Feature types accepted in the *public* feature_types argument. A stricter
69
+ # (and currently identical) set than _DISPATCH's keys would allow, kept
70
+ # separate so "not a supported FeatureType value here" and "no analyzer for
71
+ # this combination" can be reported as the distinct failures they are.
72
+ _SUPPORTED_FEATURE_TYPES = (
73
+ FeatureType.CATEGORICAL,
74
+ FeatureType.CONTINUOUS,
75
+ FeatureType.ORDINAL,
76
+ )
77
+
78
+
79
+ def _is_supported_categorical_dtype(series: pd.Series) -> bool:
80
+ """Whether ``series`` has a dtype this discovery slice treats as categorical.
81
+
82
+ Supported: ``object``, pandas ``category``, and pandas ``string``
83
+ dtypes. Numeric dtypes (including integer-coded categories) and
84
+ ``bool`` are deliberately unsupported: this slice does not infer
85
+ semantic type from values, so a numeric or boolean column is never
86
+ silently reinterpreted as categorical (see ``docs/architecture.md``).
87
+ """
88
+ dtype = series.dtype
89
+ return pd.api.types.is_object_dtype(dtype) or isinstance(
90
+ dtype, pd.CategoricalDtype | pd.StringDtype
91
+ )
92
+
93
+
94
+ def _check_ordinal_dtype(name: str, series: pd.Series) -> None:
95
+ """Require an ordered pandas ``Categorical`` for a feature declared ORDINAL.
96
+
97
+ SignaPy never infers an order (not from the values, and never
98
+ alphabetically) — the order must be explicit:
99
+ ``pd.Categorical(df[name], categories=[...], ordered=True)``.
100
+
101
+ Raises two distinct, actionable errors rather than one generic one,
102
+ since they're different mistakes with different fixes:
103
+
104
+ Raises:
105
+ ValueError: If ``series`` isn't a pandas ``category`` dtype at all
106
+ (the column needs to be built as a ``Categorical`` first), or
107
+ if it is one but ``ordered=False`` (the order declaration is
108
+ missing, not the categorical-ness itself).
109
+ """
110
+ dtype = series.dtype
111
+ if not isinstance(dtype, pd.CategoricalDtype):
112
+ raise ValueError(
113
+ f"feature {name!r} has unsupported dtype {dtype}; ordinal "
114
+ "features must be an ordered pandas Categorical — construct it "
115
+ f"with e.g. df[{name!r}] = pd.Categorical(df[{name!r}], "
116
+ "categories=[...], ordered=True) before calling discover(). "
117
+ "signapy never infers a category order from the values, and "
118
+ "never assumes alphabetical order — see docs/architecture.md."
119
+ )
120
+ if not dtype.ordered:
121
+ raise ValueError(
122
+ f"feature {name!r} is an unordered categorical "
123
+ f"(pd.Categorical(..., ordered=False)); ordinal features need "
124
+ f"an explicit declared order — construct it with "
125
+ f"pd.Categorical(df[{name!r}], categories=[...], ordered=True) "
126
+ "rather than relying on signapy to infer one — see "
127
+ "docs/architecture.md."
128
+ )
129
+
130
+
131
+ def _resolve_feature_type(name: str, declared: FeatureType | str) -> FeatureType:
132
+ """Validate and normalize one ``feature_types`` entry.
133
+
134
+ Raises:
135
+ ValueError: If ``declared`` is not a valid :class:`FeatureType`
136
+ value, or is a valid one that this discovery slice does not yet
137
+ support (anything other than
138
+ ``CATEGORICAL``/``CONTINUOUS``/``ORDINAL``).
139
+ """
140
+ if isinstance(declared, FeatureType):
141
+ feature_type = declared
142
+ else:
143
+ try:
144
+ feature_type = FeatureType(declared)
145
+ except ValueError as error:
146
+ valid = [member.value for member in FeatureType]
147
+ raise ValueError(
148
+ f"feature_types[{name!r}] = {declared!r} is not a valid "
149
+ f"FeatureType; valid values are {valid}"
150
+ ) from error
151
+ if feature_type not in _SUPPORTED_FEATURE_TYPES:
152
+ supported = [member.value for member in _SUPPORTED_FEATURE_TYPES]
153
+ raise ValueError(
154
+ f"feature_types[{name!r}] = {feature_type.value!r} is not yet "
155
+ f"supported by signapy.discover; supported feature types are "
156
+ f"{supported}"
157
+ )
158
+ return feature_type
159
+
160
+
161
+ def discover(
162
+ df: pd.DataFrame | pl.DataFrame,
163
+ target: str,
164
+ *,
165
+ positive_class: Hashable,
166
+ feature_types: Mapping[str, FeatureType | str] | None = None,
167
+ ) -> DiscoveryReport:
168
+ """Discover feature/value evidence against a binary target.
169
+
170
+ Computes feature-level evidence for each selected feature — and, for
171
+ categorical and ordinal features, value-level evidence too — and returns
172
+ them as a :class:`~signapy.results.DiscoveryReport`.
173
+
174
+ - **Categorical** features get bias-corrected Cramér's V, a chi-square
175
+ test, and per-category support/target rate/baseline rate/lift, in
176
+ order of first appearance (``FeatureResult.values`` populated).
177
+ - **Continuous** features get a point-biserial correlation and its
178
+ p-value, group counts and means in ``details``,
179
+ and per-bin support/target rate/baseline rate/lift over up to five
180
+ bins, labelled as intervals or isolated repeated values, in ascending
181
+ order
182
+ (``FeatureResult.values`` is ``None`` only if the feature can't be split
183
+ into at least two bins).
184
+ - **Ordinal** features get a Spearman rank correlation and its p-value,
185
+ plus the number of distinct levels in ``details``, and per-level
186
+ support/target rate/baseline rate/lift like categorical — but in the
187
+ feature's *declared* category order, not order of first appearance.
188
+ Ordinal features must carry an explicit order: an ordered pandas
189
+ ``Categorical`` (``pd.Categorical(..., ordered=True)``) or a Polars
190
+ ``Enum``. SignaPy never infers an order, from the values or
191
+ alphabetically.
192
+
193
+ This is SignaPy's discovery layer for binary targets only. ``df`` is
194
+ never mutated. Polars input gives the same results as the equivalent
195
+ pandas input; a Polars ``Enum`` column maps to an ordered ``Categorical``
196
+ (usable as ORDINAL), and Polars ``NaN`` is treated as missing like null.
197
+
198
+ Args:
199
+ df: The labeled dataset: a pandas or Polars ``DataFrame``. Polars
200
+ input is read without mutation and without requiring PyArrow;
201
+ see :mod:`signapy.discovery._frame` for the dtype mapping.
202
+ target: Name of the binary target column in ``df``.
203
+ positive_class: The target value treated as the positive class.
204
+ feature_types: Optional mapping from column name to
205
+ :class:`~signapy.profiling.FeatureType` (or its string value,
206
+ e.g. ``"categorical"``), selecting exactly which columns to
207
+ analyze and how. When given:
208
+
209
+ - Its keys are the *only* features analyzed; other columns of
210
+ ``df`` are ignored.
211
+ - ``target`` must not be one of the keys.
212
+ - Every key must be a column of ``df``.
213
+ - Only ``FeatureType.CATEGORICAL``, ``FeatureType.CONTINUOUS``,
214
+ and ``FeatureType.ORDINAL`` are supported in this release.
215
+
216
+ When omitted (``None``, the default), every non-target column
217
+ of ``df`` is analyzed as categorical — this preserves the
218
+ behavior from before ``feature_types`` existed, including
219
+ raising if a column's dtype isn't a supported categorical dtype.
220
+ Numeric columns are never silently treated as continuous, and
221
+ categorical columns are never silently treated as ordinal:
222
+ declaring them via ``feature_types`` is required, because e.g.
223
+ an integer column might be a measured quantity, an ordinal
224
+ level, or a category identifier, and SignaPy does not guess
225
+ which.
226
+
227
+ Returns:
228
+ A :class:`~signapy.results.DiscoveryReport` with one
229
+ :class:`~signapy.results.FeatureResult` per selected feature, in
230
+ the same order as ``df.columns`` (restricted to the selected
231
+ features when ``feature_types`` is given). For machine-readable
232
+ output (scripts, JSON, agents), call
233
+ :meth:`~signapy.results.DiscoveryReport.to_dict` — ``print(report)``
234
+ shows only one summary row per feature.
235
+
236
+ Raises:
237
+ TypeError: If ``df`` is neither a pandas nor Polars ``DataFrame``.
238
+ ValueError: If ``target`` is not a column of ``df``; if ``df`` is
239
+ empty; if ``target``'s non-missing values are not exactly two
240
+ distinct classes; if ``positive_class`` is not one of them; if
241
+ ``feature_types`` is an empty mapping, includes ``target``, or
242
+ names a column not in ``df``; if a declared feature type is
243
+ invalid or not yet supported (see :func:`_resolve_feature_type`);
244
+ if a categorical column's dtype is unsupported (see
245
+ :func:`_is_supported_categorical_dtype`); if an ordinal column
246
+ isn't an ordered ``Categorical`` (see :func:`_check_ordinal_dtype`);
247
+ or if a feature's own data is invalid for its declared type once
248
+ its missing values are dropped (see
249
+ :func:`signapy.discovery.feature.analyze_categorical_feature`,
250
+ :func:`signapy.discovery.feature.analyze_continuous_feature`,
251
+ and :func:`signapy.discovery.feature.analyze_ordinal_feature`).
252
+
253
+ Missing-data policy (see ``docs/architecture.md`` for the full
254
+ rationale), the same for every feature type:
255
+
256
+ 1. Rows with a missing ``target`` are excluded from all analysis.
257
+ 2. For each feature, rows with a missing value for *that* feature are
258
+ additionally excluded from *that feature's* analysis only; other
259
+ features are unaffected.
260
+ 3. ``FeatureResult.n`` is the number of rows with both a non-missing
261
+ target and a non-missing value for that feature.
262
+ 4. ``FeatureResult.missing_rate`` is the fraction of target-valid rows
263
+ where that feature is missing.
264
+ 5. For categorical and ordinal features, value-level baseline rate and
265
+ lift are computed from the same feature-valid rows used for that
266
+ feature's feature-level statistic.
267
+ 6. Infinite values are invalid, not missing — a continuous feature
268
+ containing ``inf``/``-inf`` raises rather than having those rows
269
+ silently dropped.
270
+ """
271
+ if is_polars_dataframe(df):
272
+ columns = list(df.columns)
273
+ if target in columns:
274
+ wanted = columns if feature_types is None else {target, *feature_types}
275
+ df = polars_to_pandas(df, wanted)
276
+ elif not isinstance(df, pd.DataFrame):
277
+ raise TypeError(
278
+ f"df must be a pandas or Polars DataFrame, got {type(df).__name__}"
279
+ )
280
+ else:
281
+ columns = list(df.columns)
282
+
283
+ if target not in columns:
284
+ raise ValueError(
285
+ f"target column {target!r} not found in DataFrame columns: {columns}"
286
+ )
287
+ if df.empty:
288
+ raise ValueError("df must not be empty")
289
+
290
+ target_valid_mask = df[target].notna()
291
+ target_valid_df = df.loc[target_valid_mask]
292
+
293
+ unique_targets = pd.unique(target_valid_df[target])
294
+ if len(unique_targets) != 2:
295
+ labels = sorted(str(value) for value in unique_targets)
296
+ raise ValueError(
297
+ f"target column {target!r} must be binary, found "
298
+ f"{len(unique_targets)} distinct non-missing classes: {labels}"
299
+ )
300
+ if positive_class not in unique_targets:
301
+ labels = sorted(str(value) for value in unique_targets)
302
+ raise ValueError(
303
+ f"positive_class {positive_class!r} is not one of the observed "
304
+ f"target classes {labels}; check for a typo or the wrong value"
305
+ )
306
+
307
+ if feature_types is None:
308
+ # Backward-compatible default: every non-target column, analyzed as
309
+ # categorical. Equivalent to declaring every column CATEGORICAL
310
+ # explicitly, so it goes through the same resolved_types/dispatch
311
+ # path below rather than a separate code path to keep in sync.
312
+ resolved_types: dict[str, FeatureType] = {
313
+ column: FeatureType.CATEGORICAL for column in df.columns if column != target
314
+ }
315
+ else:
316
+ if len(feature_types) == 0:
317
+ raise ValueError("feature_types must not be empty")
318
+ if target in feature_types:
319
+ raise ValueError(
320
+ f"feature_types must not include the target column {target!r}"
321
+ )
322
+ for name in feature_types:
323
+ if name not in columns:
324
+ raise ValueError(
325
+ f"feature_types names {name!r}, which is not a column "
326
+ f"of df: {columns}"
327
+ )
328
+ resolved_types = {
329
+ name: _resolve_feature_type(name, declared)
330
+ for name, declared in feature_types.items()
331
+ }
332
+
333
+ feature_columns = [column for column in df.columns if column in resolved_types]
334
+ results = []
335
+ for name in feature_columns:
336
+ feature_type = resolved_types[name]
337
+ series = target_valid_df[name]
338
+
339
+ analyzer = _DISPATCH.get((feature_type, TargetType.BINARY))
340
+ if analyzer is None:
341
+ # Currently unreachable: _resolve_feature_type only allows
342
+ # values in _SUPPORTED_FEATURE_TYPES, which are exactly
343
+ # _DISPATCH's keys for TargetType.BINARY (the only target type
344
+ # this layer supports). Kept as an explicit, named failure
345
+ # rather than a silent KeyError so it stays correct if either
346
+ # set changes independently in the future.
347
+ supported = sorted(f"{ft.value}+{tt.value}" for ft, tt in _DISPATCH)
348
+ raise ValueError(
349
+ f"feature {name!r} declared as {feature_type.value!r} "
350
+ f"against a {TargetType.BINARY.value!r} target has no "
351
+ f"discovery method; supported combinations are {supported}"
352
+ )
353
+
354
+ if feature_type is FeatureType.CATEGORICAL and not (
355
+ _is_supported_categorical_dtype(series)
356
+ ):
357
+ raise ValueError(
358
+ f"feature {name!r} has unsupported dtype {series.dtype}; "
359
+ "signapy.discover only supports categorical columns "
360
+ '(object, pandas "string", or pandas "category" dtype) in '
361
+ "this release. Continuous and ordinal features must be "
362
+ "declared via feature_types; boolean and integer-coded-"
363
+ "category features are not yet supported — see "
364
+ "docs/architecture.md."
365
+ )
366
+ if feature_type is FeatureType.ORDINAL:
367
+ _check_ordinal_dtype(name, series)
368
+
369
+ results.append(
370
+ analyzer(
371
+ name,
372
+ series,
373
+ target_valid_df[target],
374
+ positive_class=positive_class,
375
+ )
376
+ )
377
+
378
+ return DiscoveryReport(target=target, features=tuple(results))
@@ -0,0 +1,81 @@
1
+ """Dataframe adapter: lets ``discover`` accept a Polars ``DataFrame``.
2
+
3
+ SignaPy's analysis layer is pandas-based. Rather than converting through
4
+ ``DataFrame.to_pandas()`` (which requires PyArrow), selected Polars columns
5
+ are rebuilt as pandas Series via ``to_numpy()`` / ``to_list()``, which need
6
+ neither PyArrow nor any other optional dependency. The Polars frame is only
7
+ read, never mutated. Polars itself is never imported unless the caller
8
+ already passed a Polars object.
9
+
10
+ Dtype mapping (Polars -> pandas), chosen so that each column lands on the
11
+ same pandas dtype a user would get from the equivalent pandas workflow, and
12
+ so SignaPy's existing dtype validation behaves identically:
13
+
14
+ - integer: NumPy numeric array when complete, or the matching pandas nullable
15
+ integer dtype when nulls are present, preserving integer values exactly.
16
+ The 128-bit types, which pandas does not provide, use exact Python integers.
17
+ - float: NumPy numeric array. Nulls and NaNs become ``NaN``.
18
+ - ``String``: ``object`` dtype (``None`` for nulls).
19
+ - ``Categorical``: unordered pandas ``category``.
20
+ - ``Enum``: *ordered* pandas ``category`` in the Enum's declared order, so
21
+ an Enum column can be declared ORDINAL directly.
22
+ - ``Boolean``: ``bool``, or nullable ``boolean`` if it contains nulls
23
+ (never ``object``, so it stays unsupported as categorical/continuous
24
+ exactly like a pandas boolean column).
25
+ - ``Date`` / ``Datetime`` / ``Duration``: ``datetime64`` / ``timedelta64``.
26
+ - anything else: ``object`` dtype.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from collections.abc import Container
32
+ from typing import Any
33
+
34
+ import pandas as pd
35
+
36
+
37
+ def is_polars_dataframe(obj: object) -> bool:
38
+ """Whether ``obj`` is a Polars ``DataFrame``, without importing Polars."""
39
+ cls = type(obj)
40
+ return cls.__name__ == "DataFrame" and cls.__module__.split(".")[0] == "polars"
41
+
42
+
43
+ def _convert_series(series: Any) -> pd.Series:
44
+ import polars as pl
45
+
46
+ dtype = series.dtype
47
+ if isinstance(dtype, pl.Enum):
48
+ values: Any = pd.Categorical(
49
+ series.to_list(), categories=dtype.categories.to_list(), ordered=True
50
+ )
51
+ elif isinstance(dtype, pl.Categorical):
52
+ values = pd.Categorical(series.to_list())
53
+ elif dtype == pl.Boolean:
54
+ values = (
55
+ pd.array(series.to_list(), dtype="boolean")
56
+ if series.null_count()
57
+ else series.to_numpy()
58
+ )
59
+ elif dtype.is_integer() and "128" in str(dtype):
60
+ values = pd.Series(series.to_list(), dtype=object)
61
+ elif dtype.is_integer() and series.null_count():
62
+ values = pd.array(series.to_list(), dtype=str(dtype))
63
+ elif (dtype.is_numeric() and dtype != pl.Decimal) or (
64
+ dtype.is_temporal() and dtype != pl.Time
65
+ ):
66
+ values = series.to_numpy()
67
+ else:
68
+ values = pd.Series(series.to_list(), dtype=object)
69
+ return pd.Series(values, name=series.name)
70
+
71
+
72
+ def polars_to_pandas(df: Any, columns: Container[Any]) -> pd.DataFrame:
73
+ """Convert the ``columns`` of Polars ``df`` to a pandas DataFrame.
74
+
75
+ Column order follows ``df``. Columns not in ``columns`` are skipped
76
+ entirely (never converted), so unused columns cost nothing.
77
+ """
78
+ return pd.concat(
79
+ [_convert_series(df[name]) for name in df.columns if name in columns],
80
+ axis=1,
81
+ )