Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions AutoCarver/carvers/one_vs_rest_carver.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,11 +123,10 @@ def fit(
cv=cv,
)

# adopting the child's carved feature instances. The serial carving path
# mutates each feature in place, so the objects shared with self.features are
# already carved; the parallel path (n_jobs > 1) carves in worker processes and
# hands back *new* instances, which BaseCarver stores in the child's container
# only. Without this, self.features would keep the merely-discretized originals
# adopting the child's carved feature instances. Each carver works on its own
# copy of the features (and the parallel path, n_jobs > 1, hands back *new*
# instances from worker processes), so the carved state lives in the child's
# container only. Without this, self.features would keep the uncarved originals
# and transform would emit uncarved columns (n_mod > max_n_mod).
for carved_feature in binary_carver.features:
self.features.replace_feature(carved_feature)
Expand Down
9 changes: 8 additions & 1 deletion AutoCarver/carvers/utils/base_carver.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from abc import ABC, abstractmethod
from collections.abc import Callable, Iterable
from concurrent.futures import ThreadPoolExecutor
from copy import deepcopy
from dataclasses import dataclass, field, replace
from functools import partial
from multiprocessing import Pool
Expand Down Expand Up @@ -190,6 +191,9 @@ class BaseCarver(BaseDiscretizer, ABC):

First fits a :class:`Discretizer`. Raw data should be provided as input (not a result of
``Discretizer.transform()``).

The carver works on its own copy of ``features``: the object passed in is never
modified, and carved features are exposed on :attr:`features` (as after :meth:`load`).
"""

__name__ = "AutoCarver"
Expand Down Expand Up @@ -302,7 +306,10 @@ def __init__(
# carver-friendly defaults (dropna / ordinal_encoding True) are applied
# by BaseDiscretizer.__init__ when those toggles are left ``None``, so a
# partial config only changes the fields it sets explicitly.
super().__init__(features, min_freq=min_freq, config=config)
# the carver works on its own copy of the features: fitting never mutates the
# user's object, so the carved state lives only on ``carver.features`` (same
# as after ``load``).
super().__init__(deepcopy(features), min_freq=min_freq, config=config)

self.max_n_mod = max_n_mod
combination_evaluator.verbose = self.config.verbose
Expand Down
23 changes: 20 additions & 3 deletions AutoCarver/features/features.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,9 @@ def __init__(
datetimes : list[tuple[str, str]], optional
Datetime features as ``(column name, reference_date)`` pairs, by default ``None``.
Values are discretized as the number of seconds elapsed since ``reference_date``.
Each feature is versioned ``"<column>__ref=<reference_date>"``: use that version to
look the feature up and to read its carved column (the raw column is left as is).
A column may be listed several times with different ``reference_date``s.

nested : dict[str, list[str]], optional
Nested features as ``{output column: [parent columns coarser-ward]}``, by default
Expand Down Expand Up @@ -409,7 +412,9 @@ def __call__(self, feature_name: str | pd.DataFrame) -> BaseFeature | list[str]:
columns = [feature.version for feature in self if feature.version in feature_name.columns]
# keep companion columns that are not features themselves so they survive a
# df[features] selection: datetime reference columns and nested parent columns
companions = [feature.reference_date for feature in self.datetimes]
# raw column of datetime features first (their version isn't a raw column)
companions = [feature.name for feature in self.datetimes]
companions += [feature.reference_date for feature in self.datetimes]
companions += [parent for feature in self.nested for parent in feature.parents]
for column in companions:
if column in feature_name.columns and column not in columns:
Expand Down Expand Up @@ -853,7 +858,8 @@ def make_version(feature: TFeature, y_class: str) -> TFeature:
new_feature = type(feature).load(feature.to_json(light_mode=False))

new_feature.version_tag = y_class
new_feature.version = make_version_name(new_feature.name, y_class)
# built on the current version so datetime reference versions stay distinct per class
new_feature.version = make_version_name(new_feature.version, y_class)

return new_feature

Expand Down Expand Up @@ -923,7 +929,18 @@ def check_duplicate_features(
quantitative_names = get_versions(quantitatives)
nested_names = get_versions(nested or [])

# checking for duplicates
# checking for duplicates within each type
for kind, names in [
("ordinals", ordinal_names),
("categoricals", categorcial_names),
("quantitatives", quantitative_names),
("nested", nested_names),
]:
repeated = sorted({name for name in names if names.count(name) > 1})
if repeated:
raise ValueError(f"Provided {kind} contain duplicates: {repeated}. Please, check inputs!")

# checking for duplicates across types
duplicate = [feature in ordinal_names + quantitative_names + nested_names for feature in categorcial_names]
if any(duplicate):
raise ValueError(f"Provided categoricals found in ordinals/quantitatives: {duplicate}. Please, check inputs!")
Expand Down
24 changes: 24 additions & 0 deletions AutoCarver/features/quantitatives/datetime_feature.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ class DatetimeFeature(QuantitativeFeature):
def __init__(self, name: str, reference_date: str) -> None:
super().__init__(name)
self.reference_date = reference_date # fixed date literal or reference column name
# one column can be carved against several references: each gets its own version (column)
self.version = f"{name}__ref={reference_date}"
self.reference_is_column = False # resolved at fit time against X's columns

def fit(self, X: pd.DataFrame, y: pd.Series | None = None) -> None:
Expand Down Expand Up @@ -58,12 +60,22 @@ def to_timedelta(self, series: pd.Series, reference: pd.Series | None = None) ->
reference). Non-datetime entries (``numpy.nan``, the ``nan`` placeholder,
unparseable values) are coerced to ``numpy.nan`` so the result is a plain
float Series.

When only one side is timezone-aware, the naive side is read in that timezone
(local times that don't exist or are ambiguous in it become ``numpy.nan``).
"""
dates = pd.to_datetime(series, errors="coerce")
if reference is None:
ref = pd.to_datetime(self.reference_date)
else:
ref = pd.to_datetime(reference, errors="coerce")

dates_tz, ref_tz = _timezone(dates), _timezone(ref)
if dates_tz is None and ref_tz is not None:
dates = dates.dt.tz_localize(ref_tz, ambiguous="NaT", nonexistent="NaT")
elif dates_tz is not None and ref_tz is None:
ref = _localize(ref, dates_tz)

return (dates - ref).dt.total_seconds()

def to_json(self, light_mode: bool = False) -> dict[str, Any]:
Expand All @@ -78,6 +90,18 @@ def _restore_from_json(self, feature_json: dict) -> None:
super()._restore_from_json(feature_json)


def _timezone(value: "pd.Series | pd.Timestamp") -> Any:
"""Timezone of a datetime Series or Timestamp (``None`` when naive)."""
return value.tz if isinstance(value, pd.Timestamp) else value.dt.tz


def _localize(value: "pd.Series | pd.Timestamp", tz: Any) -> "pd.Series | pd.Timestamp":
"""Reads a naive datetime Series or Timestamp in ``tz``."""
if isinstance(value, pd.Timestamp):
return value.tz_localize(tz, ambiguous="NaT", nonexistent="NaT")
return value.dt.tz_localize(tz, ambiguous="NaT", nonexistent="NaT")


def get_datetime_features(features: Sequence[BaseFeature]) -> list[DatetimeFeature]:
"""returns datetime features amongst provided features"""
return features_of_type(features, DatetimeFeature)
6 changes: 3 additions & 3 deletions AutoCarver/mcp/server.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-29/server.schema.json",
"name": "io.github.mdefrance/autocarver",
"description": "Qualify dataset columns and process them against a target with AutoCarver, fully on your machine.",
"version": "7.7.3",
"version": "7.8.0",
"websiteUrl": "https://autocarver.readthedocs.io/en/latest/mcp.html",
"repository": {
"url": "https://github.com/mdefrance/AutoCarver",
Expand All @@ -13,7 +13,7 @@
"registryType": "pypi",
"registryBaseUrl": "https://pypi.org",
"identifier": "autocarver",
"version": "7.7.3",
"version": "7.8.0",
"transport": {
"type": "stdio"
},
Expand All @@ -33,7 +33,7 @@
"registryType": "pypi",
"registryBaseUrl": "https://pypi.org",
"identifier": "autocarver",
"version": "7.7.3",
"version": "7.8.0",
"transport": {
"type": "stdio"
},
Expand Down
4 changes: 3 additions & 1 deletion AutoCarver/selectors/utils/base_selector.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,9 @@ def __init__(
Parameters
----------
features : Features
A set of :class:`Features` to select from.
A set of :class:`Features` to select from. For carved features, pass
``carver.features``: carvers work on their own copy, so the
:class:`Features` built before carving stay raw.

n_best_features : int, optional
Total number of :class:`Features` to select, split **evenly** across
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ dev_processed = carver.transform(dev)

`min_freq` and `max_n_mod` are the only two knobs that matter to start with — the defaults (`0.02` / `5`) reflect common scoring practice, and every behavioral toggle lives in one `ProcessingConfig` object. Scan, adjust, move on.

For multiclass classification use `MulticlassCarver` (one binning per feature, against the full K-class target) — or `OneVsRestCarver` for a separate binning per class; for ordinal targets use `OrdinalCarver`; for regression use `ContinuousCarver` — the API is identical. To pre-select features by target association and inter-feature redundancy, pipe the carved output through `ClassificationSelector` or `RegressionSelector`.
For multiclass classification use `MulticlassCarver` (one binning per feature, against the full K-class target) — or `OneVsRestCarver` for a separate binning per class; for ordinal targets use `OrdinalCarver`; for regression use `ContinuousCarver` — the API is identical. To pre-select features by target association and inter-feature redundancy, pipe the carved output through `ClassificationSelector` or `RegressionSelector` with `features=carver.features` (the carver carves its own copy, so your original `features` stay raw).


## What you get
Expand All @@ -164,10 +164,10 @@ Two questions worth answering before your next model review: can you defend ever
- **No performance left on the table** — exhaustive search over admissible bin combinations maximizes Tschuprow's T (default) or Cramér's V: for fixed `min_freq`, `max_n_mod` and metric, no other combination scores higher, so you never wonder whether a better grouping existed.
- **Stop silent overfitting before production** — bins that only exist in your training sample degrade quietly under drift. Every candidate combination is validated on a dev set (and optional CV folds): any whose target rates flip or whose buckets fall below `min_freq` is rejected at fit time, not discovered in monitoring.
- **First-class ordinal features** — `OrdinalDiscretizer` enforces your declared modality order, so under-represented levels are merged with their nearest neighbour instead of being collapsed by frequency.
- **You are the final auditor** — `features.summary` and `features.history` expose the bin definitions, per-bin target rate / frequency, and the full carving trace; disagree with a boundary and you can override it, and `transform` applies your fix like any carved bin:
- **You are the final auditor** — `carver.features.summary` and `carver.features.history` expose the bin definitions, per-bin target rate / frequency, and the full carving trace; disagree with a boundary and you can override it, and `transform` applies your fix like any carved bin:

```python
feature = features("Siblings/Spouses Aboard") # any fitted feature; labels are [0, 1, 2]
feature = carver.features("Siblings/Spouses Aboard") # any carved feature; labels are [0, 1, 2]
feature.group([1], 2) # merge two bins you consider equivalent
```
- **Interpretable buckets** — human-readable boundaries you can audit, document, and ship to a scorecard.
Expand All @@ -193,7 +193,7 @@ Two questions worth answering before your next model review: can you defend ever
| Target types | any, at ~1 feature/hour | **binary, multiclass, ordinal, continuous** | binary, multiclass, continuous | n/a |
| All feature types in one `fit` (numeric, categorical, ordinal, `NaN`) | each feature is its own project | **yes — declared ordinal order enforced, `NaN` as its own modality** | yes via `BinningProcess`; no first-class ordinal type (`user_splits` workaround) | numeric only; `NaN` raises |
| Held-out dev-set robustness check | rarely — too tedious to script per feature | **yes — dev set + optional k-fold CV, built into `fit`** | no (script CV yourself) | no |
| Per-bin stats + carving history after `fit` | scattered notebook cells | **`features.summary`, `features.history`** | `binning_table` | no |
| Per-bin stats + carving history after `fit` | scattered notebook cells | **`carver.summary`, `carver.history`** | `binning_table` | no |

All three libraries are sklearn-`Pipeline` compatible; AutoCarver adds JSON round-trip persistence (`carver.save("...json")`) and feature pre-selection helpers (`ClassificationSelector`, `RegressionSelector`). The full feature matrix, side-by-side runnable snippets, and a "when to pick which" guide live on the [comparison page](https://autocarver.readthedocs.io/en/latest/comparison.html).

Expand Down
5 changes: 4 additions & 1 deletion docs/source/carvers.rst
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ Target-specific tools allow for association optimization per desired task:

All carvers share the same constructor signature:

* ``features`` (:class:`Features`) — features to carve.
* ``features`` (:class:`Features`) — features to carve. The carver works on its
own copy: fitting never modifies the object you pass, and the carved features
live on ``carver.features`` (exactly as after :meth:`~BaseCarver.load`). Use
``carver.features`` for inspection, manual edits and selection.
* ``min_freq`` (``float``) — minimum frequency per modality. Tested via the Wilson
score interval at significance ``min_freq_alpha`` (see :ref:`MinFreqViability`).
* ``max_n_mod`` (``int``) — maximum number of modalities per carved
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4006,7 +4006,7 @@
}
],
"source": [
"features[\"Pclass\"].history"
"auto_carver.features[\"Pclass\"].history"
]
},
{
Expand Down Expand Up @@ -4175,7 +4175,7 @@
"## Selectors settings\n",
"### Features to select from\n",
"\n",
"Here all features have been carved using ``BinaryCarver``, hence all features are qualitative.\n",
"Here all features have been carved using ``BinaryCarver``, hence all features are qualitative. Pass ``auto_carver.features`` to the selector: the carver works on its own copy, so the original ``features`` are left uncarved.\n",
"\n",
"### Number of features to select\n",
"\n",
Expand Down Expand Up @@ -4352,7 +4352,7 @@
"\n",
"# select the most target associated qualitative features\n",
"feature_selector = ClassificationSelector(\n",
" features=features,\n",
" features=auto_carver.features,\n",
" n_best_features=n_best_features,\n",
" config=SelectionConfig(verbose=True), # prints the per-type selection counts\n",
")\n",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4860,7 +4860,7 @@
}
],
"source": [
"features[\"AveOccup\"].history.head(7)"
"auto_carver.features[\"AveOccup\"].history.head(7)"
]
},
{
Expand All @@ -4880,7 +4880,7 @@
}
],
"source": [
"features[\"AveOccup\"].history.dev[1]"
"auto_carver.features[\"AveOccup\"].history.dev[1]"
]
},
{
Expand Down Expand Up @@ -5064,7 +5064,7 @@
"## Selectors settings\n",
"### Features to select from\n",
"\n",
"Here all features have been carved using ``ContinuousCarver``, hence all features are qualitative.\n",
"Here all features have been carved using ``ContinuousCarver``, hence all features are qualitative. Pass ``auto_carver.features`` to the selector: the carver works on its own copy, so the original ``features`` are left uncarved.\n",
"\n",
"### Number of features to select\n",
"\n",
Expand Down Expand Up @@ -5241,7 +5241,7 @@
"\n",
"# select the most target associated qualitative features\n",
"feature_selector = RegressionSelector(\n",
" features=features,\n",
" features=auto_carver.features,\n",
" n_best_features=n_best_features,\n",
" config=SelectionConfig(verbose=True), # prints the per-type selection counts\n",
")\n",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5127,7 +5127,7 @@
}
],
"source": [
"features['sepal length (cm)__y=versicolor'].history.head(7)"
"auto_carver.features['sepal length (cm)__y=versicolor'].history.head(7)"
]
},
{
Expand All @@ -5147,7 +5147,7 @@
}
],
"source": [
"features['sepal length (cm)__y=versicolor'].history.dev[1]"
"auto_carver.features['sepal length (cm)__y=versicolor'].history.dev[1]"
]
},
{
Expand Down Expand Up @@ -5348,7 +5348,7 @@
"source": [
"### Features to select from\n",
"\n",
"Here all features have been carved using ``OneVsRestCarver``, hence all features are qualitative."
"Here all features have been carved using ``OneVsRestCarver``, hence all features are qualitative. Pass ``auto_carver.features`` to the selector: the carver works on its own copy, so the original ``features`` are left uncarved."
]
},
{
Expand Down Expand Up @@ -5567,7 +5567,7 @@
"\n",
"# select the most target associated qualitative features\n",
"feature_selector = ClassificationSelector(\n",
" features=features,\n",
" features=auto_carver.features,\n",
" n_best_features=n_best_features,\n",
" config=SelectionConfig(verbose=True), # prints the per-type selection counts\n",
")\n",
Expand Down
Loading
Loading