featune 1.0.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.
featune/__init__.py ADDED
@@ -0,0 +1,83 @@
1
+ # SPDX-FileCopyrightText: 2026 Featune contributors
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """Featune public feature search API.
5
+
6
+ Exports the supported feature-search API and pinned TabPFN adapters while
7
+ keeping heavyweight model and visualization imports lazy.
8
+
9
+ Created:
10
+ 2026-09-21
11
+ """
12
+
13
+ import logging
14
+
15
+ from .budget import SearchBudget
16
+ from .compiler import FeatureCompiler
17
+ from .context import ContextBudget, ContextBuilder
18
+ from .evaluation import CVEvaluator
19
+ from .fingerprint import ExperimentFingerprint
20
+ from .ir import FeatureIR, FeatureSet
21
+ from .llm import LLMClient
22
+ from .logging_utils import ColorFormatter
23
+ from .memory import SearchMemory, SearchMemoryCompressor
24
+ from .retrieval import (
25
+ BaseColumnRetriever,
26
+ ColumnSemanticIndex,
27
+ HybridColumnRetriever,
28
+ RandomColumnRetriever,
29
+ RuleBasedColumnRetriever,
30
+ SemanticColumnRetriever,
31
+ )
32
+ from .samplers import (
33
+ AutonomousSampler,
34
+ BaseFeatureSampler,
35
+ EvolutionSampler,
36
+ HybridSampler,
37
+ LLMSampler,
38
+ RandomSampler,
39
+ )
40
+ from .schema import DatasetSchema, FeatureSpec, FieldSchema, Hypothesis, Proposal
41
+ from .study import FeatureStudy, Trial, create_study, load_study
42
+ from .tabpfn import TabPFNClassifier, TabPFNRegressor
43
+
44
+ __version__ = "1.0.0"
45
+ __all__ = [
46
+ "TabPFNClassifier",
47
+ "TabPFNRegressor",
48
+ "ContextBudget",
49
+ "ContextBuilder",
50
+ "SearchMemory",
51
+ "SearchMemoryCompressor",
52
+ "BaseColumnRetriever",
53
+ "ColumnSemanticIndex",
54
+ "SemanticColumnRetriever",
55
+ "RandomColumnRetriever",
56
+ "RuleBasedColumnRetriever",
57
+ "HybridColumnRetriever",
58
+ "SearchBudget",
59
+ "ExperimentFingerprint",
60
+ "FeatureIR",
61
+ "FeatureSet",
62
+ "BaseFeatureSampler",
63
+ "CVEvaluator",
64
+ "DatasetSchema",
65
+ "EvolutionSampler",
66
+ "FeatureCompiler",
67
+ "FeatureSpec",
68
+ "FeatureStudy",
69
+ "FieldSchema",
70
+ "HybridSampler",
71
+ "AutonomousSampler",
72
+ "Hypothesis",
73
+ "LLMClient",
74
+ "ColorFormatter",
75
+ "LLMSampler",
76
+ "Proposal",
77
+ "RandomSampler",
78
+ "Trial",
79
+ "create_study",
80
+ "load_study",
81
+ ]
82
+ logging.getLogger("featune").addHandler(logging.NullHandler())
83
+ logging.getLogger("featune").setLevel(logging.INFO)
featune/__main__.py ADDED
@@ -0,0 +1,14 @@
1
+ # SPDX-FileCopyrightText: 2026 Featune contributors
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """Module entry point.
5
+
6
+ Delegates python -m featune to the same CLI used by the installed entry point.
7
+
8
+ Created:
9
+ 2026-09-21
10
+ """
11
+
12
+ from .cli import main
13
+
14
+ main()
featune/budget.py ADDED
@@ -0,0 +1,67 @@
1
+ # SPDX-FileCopyrightText: 2026 Featune contributors
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """Persistable search budgets with explicit consumption units.
5
+
6
+ Separates cumulative search resource ceilings from per-request context limits.
7
+
8
+ Created:
9
+ 2026-09-21
10
+ """
11
+
12
+ from pydantic import Field
13
+
14
+ from .schema import Contract
15
+
16
+
17
+ class SearchBudget(Contract):
18
+ """Define cumulative resource ceilings, independent of prompt context limits.
19
+
20
+ Attributes:
21
+ max_trials (int or None): Candidate attempts, including failed and duplicate proposals.
22
+ max_model_fits (int or None): Model-fit attempts, including baseline and final refit.
23
+ max_wall_time (float or None): Active optimize wall-clock seconds across calls.
24
+ max_llm_tokens (int or None): Known cumulative input plus output tokens.
25
+ max_llm_cost (float or None): Estimated API currency units; token prices are required.
26
+ max_valid_proposals (int or None): Proposals that pass schema and parameter validation.
27
+
28
+ Notes:
29
+ None disables a ceiling. Time is checked between work units, not by interrupting
30
+ an in-progress third-party fit or HTTP call.
31
+ """
32
+
33
+ max_trials: int | None = Field(default=None, ge=0)
34
+ max_model_fits: int | None = Field(default=None, ge=0)
35
+ max_wall_time: float | None = Field(default=None, gt=0)
36
+ max_llm_tokens: int | None = Field(default=None, ge=0)
37
+ max_llm_cost: float | None = Field(default=None, ge=0)
38
+ max_valid_proposals: int | None = Field(default=None, ge=0)
39
+
40
+ def exhausted(self, usage: dict) -> str | None:
41
+ """Find the first configured ceiling reached by current consumption.
42
+
43
+ Args:
44
+ usage (dict): Consumed resources keyed without the max_ prefix; absent entries count as zero.
45
+
46
+ Returns:
47
+ str or None: Exhausted max_ field name, or None when all limits permit work.
48
+ """
49
+ for name, limit in self.model_dump().items():
50
+ if limit is not None and usage.get(name.removeprefix("max_"), 0) >= limit:
51
+ return name
52
+ return None
53
+
54
+ def remaining(self, usage: dict) -> dict:
55
+ """Calculate nonnegative remaining capacity for configured ceilings.
56
+
57
+ Args:
58
+ usage (dict): Consumed resources keyed without the max_ prefix; absent entries count as zero.
59
+
60
+ Returns:
61
+ dict[str, int or float]: Resource names without max_; unlimited resources are omitted.
62
+ """
63
+ return {
64
+ name.removeprefix("max_"): max(0, limit - usage.get(name.removeprefix("max_"), 0))
65
+ for name, limit in self.model_dump().items()
66
+ if limit is not None
67
+ }
featune/cache.py ADDED
@@ -0,0 +1,119 @@
1
+ # SPDX-FileCopyrightText: 2026 Featune contributors
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """Version-namespaced atomic memoization for trusted local studies.
5
+
6
+ Stores trusted local joblib artifacts. Namespace compatibility is supplied by
7
+ the caller; this module does not validate external artifact provenance.
8
+
9
+ Created:
10
+ 2026-09-21
11
+ """
12
+
13
+ import uuid
14
+ from pathlib import Path
15
+
16
+ import joblib
17
+
18
+ from .ir import content_hash
19
+
20
+
21
+ class ArtifactCache:
22
+ """Memoize trusted artifacts in memory or a versioned local directory.
23
+
24
+ Attributes:
25
+ namespace (str): Experiment namespace separating incompatible artifacts.
26
+ directory (Path or None): Namespace directory, or None for memory-only caching.
27
+ memory (dict): In-process entries keyed by layer and key digest.
28
+ hits (int): Successful get operations since construction.
29
+ misses (int): Unsuccessful get operations since construction.
30
+
31
+ Notes:
32
+ Disk artifacts use joblib and must never be loaded from untrusted sources.
33
+ Heavy memory layers retain at most eight insertion-ordered entries each.
34
+ """
35
+
36
+ layers = {"ir", "feature_set", "fold_transform", "matrix", "estimator", "evaluation"}
37
+
38
+ def __init__(self, directory=None, namespace="memory"):
39
+ """Initialize an empty cache and resource counters.
40
+
41
+ Args:
42
+ directory (str, Path, or None): Cache root; None selects in-memory storage.
43
+ namespace (str): Experiment identity separating incompatible cache entries.
44
+ """
45
+ self.namespace = namespace
46
+ self.directory = Path(directory) / namespace if directory else None
47
+ self.memory = {}
48
+ self.hits, self.misses = 0, 0
49
+
50
+ def _path(self, layer, key):
51
+ """Resolve a layer/key to its namespaced artifact path.
52
+
53
+ Args:
54
+ layer (str): One of ir, feature_set, fold_transform, matrix, estimator or evaluation.
55
+ key (JSON-serializable object): Deterministic artifact identity within the namespace.
56
+
57
+ Returns:
58
+ Path or None: Disk destination, or None in memory mode.
59
+
60
+ Raises:
61
+ ValueError: layer is not a registered cache layer.
62
+ """
63
+ if layer not in self.layers:
64
+ raise ValueError("Unknown cache layer")
65
+ return self.directory / layer / (content_hash(key) + ".joblib") if self.directory else None
66
+
67
+ def get(self, layer, key):
68
+ """Load an artifact and increment hit or miss accounting.
69
+
70
+ Args:
71
+ layer (str): One of ir, feature_set, fold_transform, matrix, estimator or evaluation.
72
+ key (JSON-serializable object): Deterministic artifact identity within the namespace.
73
+
74
+ Returns:
75
+ Any or None: Cached object, or None on a miss.
76
+
77
+ Notes:
78
+ Does not copy in-memory objects. Corrupt disk artifacts and I/O errors propagate
79
+ instead of silently returning an unrelated or incomplete result.
80
+ """
81
+ path = self._path(layer, key)
82
+ if path is not None and path.exists():
83
+ self.hits += 1
84
+ return joblib.load(path)
85
+ memory_key = (layer, content_hash(key))
86
+ if path is None and memory_key in self.memory:
87
+ self.hits += 1
88
+ return self.memory[memory_key]
89
+ self.misses += 1
90
+ return None
91
+
92
+ def put(self, layer, key, value):
93
+ """Store an artifact atomically on disk or with bounded in-memory retention.
94
+
95
+ Args:
96
+ layer (str): One of ir, feature_set, fold_transform, matrix, estimator or evaluation.
97
+ key (JSON-serializable object): Deterministic artifact identity within the namespace.
98
+ value (Any): Trusted joblib-serializable artifact; callers treat retrieved objects as shared.
99
+
100
+ Returns:
101
+ None: Mutates cache storage.
102
+
103
+ Notes:
104
+ Disk writes use a unique sibling temporary file followed by replacement, so
105
+ readers never observe a partially serialized destination.
106
+ """
107
+ path = self._path(layer, key)
108
+ if path is None:
109
+ self.memory[layer, content_hash(key)] = value
110
+ if layer in {"matrix", "fold_transform", "estimator"}:
111
+ # ponytail: retain eight heavy entries per layer; use disk storage for larger searches.
112
+ entries = [item for item in self.memory if item[0] == layer]
113
+ if len(entries) > 8:
114
+ del self.memory[entries[0]]
115
+ else:
116
+ path.parent.mkdir(parents=True, exist_ok=True)
117
+ temporary = path.with_suffix(f".{uuid.uuid4().hex}.tmp")
118
+ joblib.dump(value, temporary)
119
+ temporary.replace(path)
featune/cli.py ADDED
@@ -0,0 +1,200 @@
1
+ # SPDX-FileCopyrightText: 2026 Featune contributors
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """Configuration-driven search and artifact commands.
5
+
6
+ Exposes a closed configuration-driven command surface. Credentialed LLM work is
7
+ performed only by an explicitly configured optimize command.
8
+
9
+ Created:
10
+ 2026-09-21
11
+ """
12
+
13
+ import argparse
14
+ import json
15
+ import logging
16
+ from pathlib import Path
17
+
18
+ import pandas as pd
19
+ from sklearn.ensemble import (
20
+ HistGradientBoostingClassifier,
21
+ HistGradientBoostingRegressor,
22
+ RandomForestClassifier,
23
+ )
24
+ from sklearn.linear_model import LogisticRegression, Ridge
25
+
26
+ from .evaluation import CVEvaluator
27
+ from .logging_utils import ColorFormatter
28
+ from .samplers import AutonomousSampler, EvolutionSampler, HybridSampler, LLMSampler, RandomSampler
29
+ from .schema import DatasetSchema
30
+ from .study import create_study, load_study
31
+ from .tabpfn import TabPFNClassifier, TabPFNRegressor, default_estimator
32
+
33
+
34
+ def build_estimator(config, metric="auc", random_state=42):
35
+ """Instantiate an estimator from the CLI's closed model registry.
36
+
37
+ Args:
38
+ config (dict): type (default tabpfn) and optional params mapping.
39
+ metric (str): Metric identifying the task for the automatic TabPFN preset.
40
+ random_state (int): Seed for the default TabPFN preset.
41
+
42
+ Returns:
43
+ sklearn estimator: Unfitted model with the requested constructor parameters.
44
+
45
+ Raises:
46
+ ValueError: The estimator type is not registered.
47
+ ImportError: A required model dependency is missing from the installation.
48
+ """
49
+ classes = {
50
+ "logistic": LogisticRegression,
51
+ "ridge": Ridge,
52
+ "hist_classifier": HistGradientBoostingClassifier,
53
+ "hist_regressor": HistGradientBoostingRegressor,
54
+ "random_forest": RandomForestClassifier,
55
+ }
56
+ classes.update(tabpfn_classifier=TabPFNClassifier, tabpfn_regressor=TabPFNRegressor)
57
+ kind = config.get("type", "tabpfn")
58
+ if kind == "tabpfn":
59
+ return default_estimator(metric, random_state).set_params(**config.get("params", {}))
60
+ if kind.startswith("torch_"):
61
+ from .torch import TorchClassifier, TorchRegressor
62
+
63
+ classes.update(torch_classifier=TorchClassifier, torch_regressor=TorchRegressor)
64
+ if kind not in classes:
65
+ raise ValueError(f"Unknown estimator: {kind}")
66
+ return classes[kind](**config.get("params", {}))
67
+
68
+
69
+ def build_sampler(config):
70
+ """Instantiate a configured controlled sampler while rejecting inline credentials.
71
+
72
+ Args:
73
+ config (dict): type plus sampler constructor settings; hybrid has llm/random sub-configs.
74
+
75
+ Returns:
76
+ BaseFeatureSampler: Random, evolution, LLM or hybrid policy.
77
+
78
+ Raises:
79
+ ValueError: The type is unknown, an API key is embedded, or sampler settings are invalid.
80
+
81
+ Notes:
82
+ LLM credentials must come from the environment; constructing a client does not
83
+ issue an HTTP request. Model generation starts only during optimize.
84
+ """
85
+ options = dict(config)
86
+ kind = options.pop("type", "random")
87
+ if "api_key" in options or "api_key" in options.get("llm", {}):
88
+ raise ValueError("Use FEATUNE_API_KEY instead of saving api_key in CLI configuration")
89
+ classes = {"random": RandomSampler, "evolution": EvolutionSampler, "llm": LLMSampler}
90
+ if kind == "hybrid":
91
+ return HybridSampler(
92
+ llm_sampler=LLMSampler(**options.pop("llm", {})),
93
+ random_sampler=EvolutionSampler(**options.pop("random", {})),
94
+ **options,
95
+ )
96
+ if kind == "autonomous":
97
+ llm = options.pop("llm", None)
98
+ numeric = options.pop("numeric", {})
99
+ if llm is not None:
100
+ llm.setdefault("features_per_trial", 1)
101
+ return AutonomousSampler(
102
+ llm_sampler=LLMSampler(**llm) if llm is not None else None,
103
+ numeric_sampler=RandomSampler(**numeric),
104
+ **options,
105
+ )
106
+ if kind not in classes:
107
+ raise ValueError(f"Unknown sampler: {kind}")
108
+ return classes[kind](**options)
109
+
110
+
111
+ def main(argv=None):
112
+ """Dispatch configuration-driven optimization, inspection, reporting or feature export.
113
+
114
+ Args:
115
+ argv (sequence[str] or None): Command-line arguments; None reads sys.argv[1:].
116
+
117
+ Returns:
118
+ None: Writes command output and any requested artifacts.
119
+
120
+ Raises:
121
+ SystemExit: argparse handles help or invalid command arguments.
122
+
123
+ Notes:
124
+ Configuration paths resolve relative to the config file. optimize can perform
125
+ network requests when an LLM sampler is explicitly configured; repeated runs
126
+ add attempts rather than acting as idempotent queries. Errors from data loading,
127
+ validation, fitting and persistence propagate to the command caller.
128
+ """
129
+ parser = argparse.ArgumentParser(prog="featune", description="Semantic feature engineering search")
130
+ parser.add_argument("--verbose", action="store_true")
131
+ commands = parser.add_subparsers(dest="command", required=True)
132
+ optimize = commands.add_parser("optimize", help="Run or resume from a JSON configuration")
133
+ optimize.add_argument("config", type=Path)
134
+ for command in ("report", "export", "inspect"):
135
+ sub = commands.add_parser(command)
136
+ sub.add_argument("--storage", required=True)
137
+ sub.add_argument("--study-name", default="default")
138
+ if command != "inspect":
139
+ sub.add_argument("--output", type=Path, required=True)
140
+ args = parser.parse_args(argv)
141
+ if not logging.getLogger().handlers:
142
+ logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO)
143
+ handler = logging.getLogger().handlers[0]
144
+ handler.setFormatter(
145
+ ColorFormatter("%(asctime)s | %(levelname)s | %(message)s", color=handler.stream.isatty())
146
+ )
147
+ if args.command == "optimize":
148
+ config = json.loads(args.config.read_text(encoding="utf-8"))
149
+ base = args.config.resolve().parent
150
+ dataset = config["data"]
151
+ path = base / dataset["path"]
152
+ data = pd.read_parquet(path) if path.suffix == ".parquet" else pd.read_csv(path)
153
+ y = data.pop(dataset["target"])
154
+ groups = data.pop(dataset["groups"]) if dataset.get("groups") else None
155
+ schema_config = config["schema"]
156
+ if isinstance(schema_config, str):
157
+ schema_config = json.loads((base / schema_config).read_text(encoding="utf-8"))
158
+ schema = DatasetSchema.model_validate(schema_config)
159
+ if dataset["target"] in schema.usable:
160
+ raise ValueError("Target must not be a usable schema field")
161
+ options = dict(config.get("study", {}))
162
+ sampler = build_sampler(
163
+ config.get(
164
+ "sampler", {"type": "autonomous"} if options.get("search_strategy") == "autonomous" else {}
165
+ )
166
+ )
167
+ options["storage"] = base / options.get("storage", "runs")
168
+ study = create_study(sampler=sampler, **options)
169
+ evaluation = dict(config.get("evaluation", {}))
170
+ evaluation.setdefault("metric", study.metric)
171
+ estimator = build_estimator(config.get("estimator", {}), evaluation["metric"], study.random_state)
172
+ evaluator = CVEvaluator(estimator, **evaluation)
173
+ study.optimize(data, y, schema, evaluator=evaluator, groups=groups, **config.get("optimize", {}))
174
+ destination = study.storage.directory
175
+ completed = any(t.state == "COMPLETE" for t in study.trials)
176
+ if hasattr(study, "pipeline_"):
177
+ study.export_pipeline(destination / "pipeline.joblib")
178
+ if completed:
179
+ study.export_features(destination / "features.json")
180
+ study.trials_dataframe().to_json(destination / "trials.json", orient="records", indent=2)
181
+ if completed and config.get("report", False):
182
+ study.report(destination / "report.html")
183
+ print(
184
+ json.dumps(
185
+ {
186
+ "best_value": study.best_value if completed else None,
187
+ "best_trial": study.best_trial.number if completed else None,
188
+ "stop_reason": study.stop_reason,
189
+ "directory": str(destination),
190
+ }
191
+ )
192
+ )
193
+ else:
194
+ study = load_study(args.storage, args.study_name)
195
+ if args.command == "report":
196
+ study.report(args.output)
197
+ elif args.command == "export":
198
+ study.export_features(args.output)
199
+ else:
200
+ print(study.trials_dataframe().drop(columns=["features", "hypotheses"]).to_string(index=False))