metafit 0.1.1__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.
metafit/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """MetaFit rework: agents, decision trees, and training."""
metafit/agents.py ADDED
@@ -0,0 +1,162 @@
1
+ """Text generation with local Hugging Face models or OpenAI-compatible APIs.
2
+
3
+ Dependencies are imported only when their respective agent is initialized.
4
+ Each call to ``generate`` is independent and returns the generated text.
5
+ """
6
+
7
+ from abc import ABC, abstractmethod
8
+ from typing import Any
9
+
10
+
11
+ class Agent(ABC):
12
+ """Shared interface for language-model agents."""
13
+
14
+ @abstractmethod
15
+ def generate(
16
+ self,
17
+ prompt: str,
18
+ system_prompt: str | None = None,
19
+ **generation_kwargs: Any,
20
+ ) -> str:
21
+ """Return generated text for a prompt."""
22
+ pass
23
+
24
+
25
+ class LocalAgent(Agent):
26
+ """Load a causal model with separate loading and generation options.
27
+
28
+ ``model_kwargs`` accepts dtype, device_map, quantization_config, revision,
29
+ and other model-loading options. ``tokenizer_kwargs`` configures tokenizer
30
+ loading. ``generation_kwargs`` and ``chat_template_kwargs`` store defaults
31
+ that can be overridden in ``generate``.
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ model_name: str,
37
+ temperature: float = 0.7,
38
+ *,
39
+ device: str | None = None,
40
+ max_new_tokens: int = 512,
41
+ model_kwargs: dict[str, Any] | None = None,
42
+ tokenizer_kwargs: dict[str, Any] | None = None,
43
+ generation_kwargs: dict[str, Any] | None = None,
44
+ chat_template_kwargs: dict[str, Any] | None = None,
45
+ ) -> None:
46
+ import torch
47
+ from transformers import AutoModelForCausalLM, AutoTokenizer
48
+
49
+ self.model_name = model_name
50
+ self.temperature = temperature
51
+ self.max_new_tokens = max_new_tokens
52
+ self.generation_kwargs = dict(generation_kwargs or {})
53
+ self.chat_template_kwargs = dict(chat_template_kwargs or {})
54
+ self.tokenizer = AutoTokenizer.from_pretrained(model_name, **(tokenizer_kwargs or {}))
55
+ self.model = AutoModelForCausalLM.from_pretrained(model_name, **(model_kwargs or {}))
56
+ if (model_kwargs or {}).get("device_map") is None:
57
+ self.model.to(device or ("cuda" if torch.cuda.is_available() else "cpu"))
58
+ self.model.eval()
59
+
60
+ def generate(
61
+ self,
62
+ prompt: str,
63
+ system_prompt: str | None = None,
64
+ *,
65
+ chat_template_kwargs: dict[str, Any] | None = None,
66
+ **generation_kwargs: Any,
67
+ ) -> str:
68
+ """Generate text, overriding stored generation and template defaults.
69
+
70
+ A zero temperature selects greedy decoding. Models with a chat template
71
+ receive formatted messages; other models receive plain text.
72
+ """
73
+ import torch
74
+
75
+ if self.tokenizer.chat_template:
76
+ messages = []
77
+ if system_prompt:
78
+ messages.append({"role": "system", "content": system_prompt})
79
+ messages.append({"role": "user", "content": prompt})
80
+ text = self.tokenizer.apply_chat_template(
81
+ messages,
82
+ tokenize=False,
83
+ add_generation_prompt=True,
84
+ **{**self.chat_template_kwargs, **(chat_template_kwargs or {})},
85
+ )
86
+ inputs = self.tokenizer(
87
+ text, return_tensors="pt", add_special_tokens=False
88
+ )
89
+ else:
90
+ text = f"{system_prompt}\n\n{prompt}" if system_prompt else prompt
91
+ inputs = self.tokenizer(text, return_tensors="pt")
92
+ inputs = inputs.to(self.model.device)
93
+
94
+ params = {
95
+ "max_new_tokens": self.max_new_tokens,
96
+ "temperature": self.temperature,
97
+ **self.generation_kwargs,
98
+ **generation_kwargs,
99
+ }
100
+ temperature = params.pop("temperature")
101
+ params.setdefault("do_sample", temperature > 0)
102
+ if params["do_sample"]:
103
+ params["temperature"] = temperature
104
+ pad_token_id = self.tokenizer.pad_token_id
105
+ if pad_token_id is None:
106
+ pad_token_id = self.tokenizer.eos_token_id
107
+ if pad_token_id is not None:
108
+ params.setdefault("pad_token_id", pad_token_id)
109
+
110
+ with torch.inference_mode():
111
+ output = self.model.generate(**inputs, **params)
112
+ sequences = output.sequences if hasattr(output, "sequences") else output
113
+ new_tokens = sequences[0, inputs["input_ids"].shape[-1]:]
114
+ return self.tokenizer.decode(new_tokens, skip_special_tokens=True)
115
+
116
+
117
+ class APIAgent(Agent):
118
+ """Generate text with OpenAI Chat Completions or a compatible endpoint.
119
+
120
+ ``client_kwargs`` accepts timeout, max_retries, and other client options.
121
+ ``generation_kwargs`` stores endpoint-specific defaults such as top_p,
122
+ max_completion_tokens, response_format, reasoning_effort, and extra_body.
123
+ Use ``temperature=None`` to omit temperature from requests.
124
+ """
125
+
126
+ def __init__(
127
+ self,
128
+ api_key: str | None = None,
129
+ model_name: str = "gpt-4",
130
+ temperature: float | None = 0.7,
131
+ *,
132
+ base_url: str | None = None,
133
+ client_kwargs: dict[str, Any] | None = None,
134
+ generation_kwargs: dict[str, Any] | None = None,
135
+ ) -> None:
136
+ """Use the supplied key, or OPENAI_API_KEY when it is omitted."""
137
+ from openai import OpenAI
138
+
139
+ self.model_name = model_name
140
+ self.temperature = temperature
141
+ self.generation_kwargs = dict(generation_kwargs or {})
142
+ self.client = OpenAI(api_key=api_key, base_url=base_url, **(client_kwargs or {}))
143
+
144
+ def generate(
145
+ self,
146
+ prompt: str,
147
+ system_prompt: str | None = None,
148
+ **generation_kwargs: Any,
149
+ ) -> str:
150
+ """Return one non-streaming reply, overriding stored generation defaults."""
151
+ messages = []
152
+ if system_prompt:
153
+ messages.append({"role": "system", "content": system_prompt})
154
+ messages.append({"role": "user", "content": prompt})
155
+
156
+ params = {"temperature": self.temperature, **self.generation_kwargs, **generation_kwargs}
157
+ if params["temperature"] is None:
158
+ params.pop("temperature")
159
+ response = self.client.chat.completions.create(
160
+ model=self.model_name, messages=messages, **params
161
+ )
162
+ return response.choices[0].message.content
metafit/logger.py ADDED
@@ -0,0 +1,136 @@
1
+ """Training records, resume state, and analysis output in one place."""
2
+
3
+ import hashlib
4
+ import json
5
+ from pathlib import Path
6
+
7
+ from .tree import Candidate, LLMDecisionTree
8
+ from .utils.analysis import (
9
+ save_config, save_json, save_training_log, save_final_result,
10
+ plot_training_progress, plot_test_performance,
11
+ )
12
+
13
+
14
+ class TrainerLogger:
15
+ def __init__(self, output_dir, resume=True):
16
+ self.output_dir = Path(output_dir)
17
+ self.resume = resume
18
+ self.history = []
19
+ self.candidates = []
20
+ self.cached_selection = None
21
+ self.agent_settings = {}
22
+ self.config = None
23
+
24
+ def save_inputs(self, config, split_indices):
25
+ """Record experiment configuration, seeds, and original row indices."""
26
+ self.agent_settings = {key: config[key] for key in [
27
+ "backend", "model_name", "temperature", "generation_kwargs",
28
+ "model_kwargs", "tokenizer_kwargs", "chat_template_kwargs",
29
+ ]}
30
+ self.config = config
31
+ self.split_indices = split_indices
32
+
33
+ def restore(self, trainer, batches):
34
+ """Check the saved footprint and rebuild completed trees, never refitting."""
35
+ self.history, self.candidates = [], []
36
+ self.cached_selection = None
37
+ settings = {key: getattr(trainer, key) for key in [
38
+ "random_state", "updates_per_epoch", "batch_size", "train_method",
39
+ "selection_metric", "regularizer_candidate_count", "stratify_batches",
40
+ "misclassified_top_k", "de_config", "data_config",
41
+ ]}
42
+ agent_settings = self.agent_settings or {
43
+ key: getattr(trainer.agent, key, None)
44
+ for key in ["model_name", "temperature", "generation_kwargs", "chat_template_kwargs"]
45
+ }
46
+ settings["agent_fingerprint"] = hashlib.sha256(
47
+ json.dumps(agent_settings, sort_keys=True, default=str).encode()
48
+ ).hexdigest()
49
+ for name, frame in [("train", trainer.train_df), ("val", trainer.val_df)]:
50
+ settings[name + "_fingerprint"] = hashlib.sha256(
51
+ frame.reset_index(drop=True).to_json(orient="table", double_precision=15).encode()
52
+ ).hexdigest()
53
+ settings = json.loads(json.dumps(settings))
54
+ path = self.output_dir / "training_state.json"
55
+ if self.resume and path.exists() and json.loads(path.read_text()) != settings:
56
+ raise ValueError("Training data or settings changed. Use a new output folder.")
57
+ save_json(path, settings)
58
+ if self.config is not None:
59
+ save_config(self.output_dir, self.config)
60
+ save_json(self.output_dir / "split.json", self.split_indices)
61
+ log_path = self.output_dir / "training_log.json"
62
+ if not self.resume or not log_path.exists():
63
+ return self.history, self.candidates
64
+
65
+ features = [c for c in trainer.train_df.columns if c != trainer.data_config["output_col"]]
66
+ for entry in json.loads(log_path.read_text(encoding="utf-8")):
67
+ restored = []
68
+ for record in entry["candidates"]:
69
+ tree = LLMDecisionTree(record["fitted_model"], feature_names=features)
70
+ tree.proposal_json = record["proposal"]
71
+ candidate = Candidate(
72
+ tree, record["update"], record["candidate_index"],
73
+ {"train": trainer.train_df if trainer.batch_size == 0 else batches[record["update"] - 1],
74
+ "val": trainer.val_df, "full_train": trainer.train_df},
75
+ trainer.data_config["output_col"],
76
+ )
77
+ for key in ["bootstrap_mean", "bootstrap_std", "bootstrap_valid_trials"]:
78
+ setattr(candidate, key, record[key])
79
+ restored.append(candidate)
80
+ entry["candidate"] = next(c for c in restored if c.candidate_index == entry["selected_candidate_index"])
81
+ self.history.append(entry)
82
+ self.candidates.extend(restored)
83
+ selection = self.output_dir / "final_model_selection.json"
84
+ if selection.exists():
85
+ self.cached_selection = json.loads(selection.read_text(encoding="utf-8"))
86
+ return self.history, self.candidates
87
+
88
+ def log_update(self, trainer, update, prompt, feedback):
89
+ self.history.append({
90
+ "epoch": (update - 1) // trainer.updates_per_epoch + 1,
91
+ "batch": (update - 1) % trainer.updates_per_epoch + 1,
92
+ "update": update, "prompt": prompt,
93
+ "response": trainer.generation_attempts_[-1]["response"],
94
+ "attempts": trainer.generation_attempts_,
95
+ "candidate": trainer.current_candidate_, "sample_feedback": feedback,
96
+ })
97
+ self.save_training()
98
+
99
+ def save_training(self):
100
+ save_training_log(self.output_dir, self.history, self.candidates)
101
+
102
+ def selection_response(self, prompt, total_updates):
103
+ """Return a reusable reply, or None when the LLM must select again."""
104
+ cached = self.cached_selection
105
+ if cached and len(self.history) == total_updates and cached.get("prompt") == prompt:
106
+ return cached["response"]
107
+ return None
108
+
109
+ def save_selection(self, trainer):
110
+ selected = trainer.selected_candidate_
111
+ record = {
112
+ "method": trainer.return_weights, "metric": trainer.selection_metric,
113
+ "bootstrap_iterations": trainer.bootstrap_selection_iterations,
114
+ "bootstrap_sample_size": (trainer.bootstrap_selection_sample_size or len(trainer.val_df))
115
+ if trainer.bootstrap_selection_iterations > 0 else None,
116
+ "selected_candidate_rank": self.candidates.index(selected) + 1,
117
+ "selected_update": selected.update, "selected_candidate_index": selected.candidate_index,
118
+ "selected_bootstrap_mean": selected.bootstrap_mean,
119
+ "selected_bootstrap_std": selected.bootstrap_std,
120
+ "candidates": [
121
+ {"rank": rank, "update": c.update, "candidate_index": c.candidate_index,
122
+ "validation_score": c.metrics["val"][trainer.selection_metric],
123
+ "bootstrap_mean": c.bootstrap_mean, "bootstrap_std": c.bootstrap_std,
124
+ "bootstrap_valid_trials": c.bootstrap_valid_trials}
125
+ for rank, c in enumerate(self.candidates, start=1)
126
+ ],
127
+ }
128
+ if trainer.return_weights == "llm_polish":
129
+ record.update(prompt=trainer.selection_prompt_, response=trainer.selection_response_)
130
+ save_json(self.output_dir / "final_model_selection.json", record)
131
+ return record
132
+
133
+ def save_result(self, result, metric="f1"):
134
+ save_final_result(self.output_dir, result)
135
+ plot_training_progress(self.output_dir, metric)
136
+ plot_test_performance(self.output_dir)
@@ -0,0 +1,306 @@
1
+ """Prompt templates and the prompt builder used during a MetaFit run."""
2
+
3
+ from typing import Any
4
+
5
+ import pandas as pd
6
+ from ..tree import Candidate
7
+
8
+ from ..utils.process import (
9
+ format_candidate_history,
10
+ format_feedback,
11
+ serialize_examples,
12
+ wrap_multiple_candidates,
13
+ )
14
+
15
+
16
+ def task_instructions(
17
+ output_col: str,
18
+ feature_names: list[str],
19
+ *,
20
+ task: str = "classification",
21
+ train_method: str = "exact_tree",
22
+ selection_metric: str = "f1",
23
+ hide_context: bool = False,
24
+ ) -> str:
25
+ """Describe the decision-tree task and misclassified-sample feedback."""
26
+ ml_model = "DecisionTree"
27
+ predictor_columns = feature_names
28
+ if hide_context:
29
+ metadata_guidance = f"""
30
+ NO-METADATA MODE IS ACTIVE.
31
+ The task context has intentionally been hidden. Do not try to infer, guess, recover, or name which real-world dataset this batch may come from.
32
+ Do not use external dataset memory, benchmark familiarity, domain priors, medical knowledge, UCI/OpenML knowledge, or any assumption about the source of these rows.
33
+ Even if feature names, class labels, feature ranges, or value patterns look familiar, treat them only as anonymous columns from an unknown tabular prediction task.
34
+ Fit the {ml_model} only from the provided labeled batch, the previous fitted model if present, and the explicit feedback signal. Use empirical relationships in these rows and feedback, not metadata.
35
+ If you recognize a possible dataset identity, ignore that recognition and continue as if the dataset identity is unknown."""
36
+ else:
37
+ metadata_guidance = "Think very carefully about what the predictors mean. Use your internal knowledge to estimate plausible model parameters or parameter ranges."
38
+
39
+ if train_method == "interval_tree":
40
+ proposal_description = "decision-tree structures whose thresholds are lower/upper ranges"
41
+ optimizer_guidance = f"""A numeric optimizer will choose exact thresholds within your ranges using a separate validation split and will target the selected metric: {selection_metric}. You do not see those validation rows. Propose ranges that should generalize to that metric rather than trying to reconstruct unseen validation cases."""
42
+ elif train_method == "exact_tree":
43
+ proposal_description = "decision-tree structures with exact numeric thresholds"
44
+ optimizer_guidance = f"""No numeric optimizer will alter your thresholds. Each exact tree will be evaluated directly on a separate validation split using the selected metric: {selection_metric}. You do not see those validation rows. Propose exact thresholds that should generalize to that metric rather than trying to reconstruct unseen validation cases."""
45
+ else:
46
+ raise ValueError(
47
+ f"Unsupported train_method={train_method!r}. "
48
+ "Choose 'exact_tree' or 'interval_tree'."
49
+ )
50
+
51
+ tabllm_example_note = """
52
+ The current batch below acts as few-shot labeled examples for this update.
53
+ The examples are serialized as natural-language feature-value statements.
54
+ Use them to understand the feature meanings and class-label patterns, but do not directly classify rows.
55
+ Return only the required JSON model."""
56
+ return f"""You are going to output {proposal_description} for a {ml_model} that predicts {output_col} from {predictor_columns}. It is a {task} problem.
57
+ {metadata_guidance}
58
+ {optimizer_guidance}
59
+ When feedback is provided, it contains the previous fitted model, grouped full-training-set error types, a scheduled number of high-confidence misclassified samples to fix, and the remaining feedback budget as high-confidence correct samples to preserve. For DecisionTree feedback, samples may also include the decision path taken through the fitted tree.
60
+ Use the previous fitted model as your starting point and adjust it conservatively. Prefer local corrections around its fitted thresholds over a completely new model. Change only the problematic subtree or thresholds suggested by the error groups and decision paths.
61
+ Treat high-confidence correct samples as do-no-harm constraints. Do not change branches or threshold regions that explain those correct predictions unless the grouped errors strongly justify it.
62
+ Use grouped error patterns to make structural corrections instead of overreacting to one row. Preserve useful thresholds; change them only when the error groups, misclassified-sample confidence, and decision paths suggest a specific correction.
63
+ Avoid overfitting to one misclassified sample, and do not infer broad changes from low-confidence errors alone.
64
+ Your output will be directly parsed by machine learning code, therefore make sure you output it correctly.
65
+ The current batch I am going to provide acts as few-shot examples for this update.
66
+ {tabllm_example_note}
67
+ You should not construct the tree entirely from the samples I provide to you.
68
+ You should follow very strictly to the output format that I am going to provide to you.
69
+ Do not output anything else other than my given output format. Do not wrap the output in markdown, start with a bracket not '.
70
+ IMPORTANT: Output valid JSON only. Use lowercase true/false for boolean values, not Python-style True/False. Use double quotes for all strings and keys."""
71
+
72
+
73
+ def base_prompt(
74
+ role_description: str,
75
+ task_description: str,
76
+ few_shot_examples: str,
77
+ *,
78
+ context_description: str = "",
79
+ feedback: str = "",
80
+ output_schema: str = "",
81
+ ) -> str:
82
+ """Assemble an update prompt; feedback already includes its heading."""
83
+ parts = [f"{role_description}\n", f"{task_description}\n"]
84
+ if context_description:
85
+ parts.append(f"Here is the context of the task. {context_description}\n")
86
+ if few_shot_examples:
87
+ parts.append(f"Here are some data from the task:\n{few_shot_examples}\n")
88
+ if feedback:
89
+ parts.append(feedback)
90
+ if output_schema:
91
+ parts.append(f"Here is the output format:{output_schema}\n")
92
+ return "\n".join(parts)
93
+
94
+
95
+ def feedback_prompt(tree: str | None, misclassified_samples: str = "") -> str:
96
+ """Build the previous-attempt feedback section, or omit it when empty."""
97
+ feedback = format_feedback(tree, misclassified_samples)
98
+ if not feedback:
99
+ return ""
100
+ return f"""Here is the feedback from your previous attempt.
101
+
102
+ Think critically on how you can improve the model based on the feedback, {feedback}\n"""
103
+
104
+
105
+ def output_format(
106
+ task: str = "classification",
107
+ ml_model: str = "DecisionTree",
108
+ train_method: str = "exact_tree",
109
+ ) -> str:
110
+ """Return the decision-tree JSON output instructions for the strategy.
111
+
112
+ ``exact_tree`` requests exact parameters. ``interval_tree`` requests
113
+ parameter intervals. Template whitespace is preserved for compatibility.
114
+ """
115
+ formats = {
116
+ "classification": {
117
+ "DecisionTree": {
118
+ "format": """{
119
+ "rationale": "<Brief model rationale: explain the feature-target relationships and causality used in the model. Use the error samples only to test this rationale; do not rely on them heavily.>",
120
+ "tree": {
121
+ "feature": "<feature_name>",
122
+ "threshold": <threshold_value>,
123
+ "left": {
124
+ "feature": "<feature_name>",
125
+ "threshold": <threshold_value>,
126
+ "left": {
127
+ "value": [<class_1>, <class_2>, ...]
128
+ },
129
+ "right": {
130
+ "value": [<class_1>, <class_2>, ...]
131
+ }
132
+ },
133
+ "right": {
134
+ "value": [<class_1>, <class_2>, ...]
135
+ }
136
+ },
137
+ "classes": [<class_label_1>, <class_label_2>, ...]
138
+ }
139
+
140
+ *note:
141
+ - Internal (split) nodes MUST have "feature" and "threshold".
142
+ - Leaf nodes MUST have "value": [<class_1>, <class_2>, ...] and NO "feature" or "threshold".
143
+ - You can nest "left" and "right" as deeply as needed to fit the data.
144
+ - For each leaf, set the predicted class to 1 and all others to 0.
145
+ """,
146
+ "regularizer_format": """{
147
+ "rationale": "<Brief model rationale: explain the feature-target relationships and causality used in the model. Use the error samples only to test this rationale; do not rely on them heavily.>",
148
+ "tree": {
149
+ "feature": "<feature_name>",
150
+ "threshold": {"lower": <lower_bound>, "upper": <upper_bound>},
151
+ "left": {
152
+ "feature": "<feature_name>",
153
+ "threshold": {"lower": <lower_bound>, "upper": <upper_bound>},
154
+ "left": {
155
+ "value": [<class_1>, <class_2>, ...]
156
+ },
157
+ "right": {
158
+ "value": [<class_1>, <class_2>, ...]
159
+ }
160
+ },
161
+ "right": {
162
+ "value": [<class_1>, <class_2>, ...]
163
+ }
164
+ },
165
+ "classes": [<class_label_1>, <class_label_2>, ...]
166
+ }
167
+
168
+ *note:
169
+ - Internal (split) nodes MUST have "feature" and "threshold". The "threshold" must be an object with "lower" and "upper" bounds.
170
+ - Leaf nodes MUST have "value": [<class_1>, <class_2>, ...] and NO "feature" or "threshold".
171
+ - "classes" field must have names that exactly match the labels. Do not use descriptive name inside "classes" otherwise there would be an error.
172
+ - You can nest "left" and "right" as deeply as needed to fit the data.
173
+ - For each leaf, set the predicted class to 1 and all others to 0.
174
+ - The "lower" and "upper" bounds define the acceptable range for the split threshold. The actual threshold will be aggressively optimized on the validation set. Make sure it will generalize well. If you see a perfect score, adjust the ranges to be narrower or prune the tree to prevent overfitting.
175
+ """,
176
+ },
177
+ },
178
+ }
179
+ if train_method == "exact_tree":
180
+ format_name = "format"
181
+ elif train_method == "interval_tree":
182
+ format_name = "regularizer_format"
183
+ else:
184
+ raise ValueError(
185
+ f"Unsupported train_method={train_method!r}. "
186
+ "Choose 'exact_tree' or 'interval_tree'."
187
+ )
188
+ return formats[task][ml_model][format_name]
189
+
190
+ def candidate_selection_prompt(
191
+ context: str,
192
+ history: str,
193
+ selection_metric: str = "f1",
194
+ feature_names: list[str] | None = None,
195
+ ) -> str:
196
+ """Ask the LLM to select one existing fitted candidate by displayed rank."""
197
+ feature_schema = ", ".join(feature_names) if feature_names else "Not available"
198
+ return f"""Select one final model from all exact fitted candidates below.
199
+ A Base ML reference candidate may also be included. Each candidate was evaluated on repeated bootstrap samples from the validation set; its bootstrap mean and standard deviation are reported for the selection metric ({selection_metric}), and full-training performance is included as additional fit context.
200
+ The candidate list can include both LLM-generated fitted models and a CV-tuned base ML model. Treat them fairly: do not assume an LLM candidate is better merely because it was generated by an LLM. Prefer candidates with high bootstrap mean and stable performance (low standard deviation), while using the dataset context only to resolve meaningful model-structure choices.
201
+
202
+ Dataset context:
203
+ {context if context else "Not available"}
204
+
205
+ Available feature names:
206
+ {feature_schema}
207
+
208
+ Selection procedure:
209
+ 1. Treat train performance and validation performance as the primary evidence. High validation performance doesn't necessarily mean a good model, it could be overfitted on the validation set when train performance is significantly lower then validation performance. Reject models with big gap between train performance and val performance unless there is no any other choice.
210
+ 2. Prefer a high bootstrap mean with a low bootstrap standard deviation.
211
+ 3. Use full-training performance to identify unstable train-validation behavior.
212
+ 4. When candidates have similar statistical evidence, prefer the simpler and more clinically plausible model.
213
+ 5. Use dataset knowledge only as a tie-breaker. Do not ignore substantially better validation evidence merely because another model appears more plausible.
214
+
215
+ A perfect training or validation score may indicate overfitting, but it is not sufficient evidence by itself. Consider model complexity, train-validation agreement, bootstrap variability, and feature plausibility together.
216
+
217
+ Your only job is to choose exactly one existing candidate. Do not create, refine, merge, or modify a model. The selected candidate's stored fitted model will be returned unchanged. If models have similar train and validation performance, prefer models with training performance higher than the validation performance, and the gap between these performances should be small. Overall, your job is to select a model that generalizes the best.
218
+ Here are all exact fitted LLM candidates and the optional Base ML reference. Each listed tree is ready for inference and will be returned unchanged; no batch-training performance is shown: {history}.
219
+
220
+ Output only this JSON object, without markdown or explanation:
221
+ {{"selected_candidate_rank": <integer from 1 through the number of listed candidates>}}"""
222
+
223
+
224
+ class PromptBuilder:
225
+ """Build DecisionTree prompts from fixed settings and per-call data.
226
+
227
+ Metadata requires ``output_col`` and ``role_description``. Optional keys
228
+ are ``task`` (default ``classification``), ``context_description``, and
229
+ ``hide_context``. Examples and feedback are never stored on the builder.
230
+ """
231
+
232
+ def __init__(
233
+ self,
234
+ metadata: dict[str, Any],
235
+ *,
236
+ train_method: str = "exact_tree",
237
+ selection_metric: str = "f1",
238
+ candidate_count: int = 5,
239
+ random_state: int = 114514,
240
+ rounding: int = 3,
241
+ ) -> None:
242
+ if candidate_count < 1:
243
+ raise ValueError("candidate_count must be positive.")
244
+ self.metadata = metadata.copy()
245
+ self.train_method = train_method
246
+ self.selection_metric = selection_metric
247
+ self.candidate_count = candidate_count
248
+ self.random_state = random_state
249
+ self.rounding = rounding
250
+
251
+ def build_update_prompt(
252
+ self,
253
+ examples: pd.DataFrame,
254
+ previous_tree: str | None = None,
255
+ sample_feedback: str = "",
256
+ ) -> str:
257
+ """Build a complete update prompt; an empty batch gives zero-shot input."""
258
+ output_col = self.metadata["output_col"]
259
+ task = self.metadata.get("task", "classification")
260
+ feature_names = [column for column in examples.columns if column != output_col]
261
+ examples_text = serialize_examples(
262
+ examples,
263
+ output_col,
264
+ random_state=self.random_state,
265
+ rounding=self.rounding,
266
+ )
267
+ instructions = task_instructions(
268
+ output_col,
269
+ feature_names,
270
+ task=task,
271
+ train_method=self.train_method,
272
+ selection_metric=self.selection_metric,
273
+ hide_context=bool(self.metadata.get("hide_context", False)),
274
+ )
275
+ prompt = base_prompt(
276
+ role_description=self.metadata["role_description"],
277
+ task_description=instructions,
278
+ few_shot_examples=examples_text,
279
+ context_description=self.metadata.get("context_description", ""),
280
+ feedback=feedback_prompt(previous_tree, sample_feedback),
281
+ output_schema=output_format(task=task, train_method=self.train_method),
282
+ )
283
+ return wrap_multiple_candidates(
284
+ prompt,
285
+ self.candidate_count,
286
+ deterministic=self.train_method == "exact_tree",
287
+ )
288
+
289
+ def build_selection_prompt(
290
+ self,
291
+ candidates: list[Candidate],
292
+ feature_names: list[str],
293
+ ) -> str:
294
+ """Describe candidates in their supplied display order and request a rank."""
295
+ history = format_candidate_history(candidates, self.selection_metric)
296
+ return candidate_selection_prompt(
297
+ context=self.metadata.get("context_description", ""),
298
+ history=history,
299
+ selection_metric=self.selection_metric,
300
+ feature_names=feature_names,
301
+ )
302
+
303
+ @staticmethod
304
+ def build_retry_prompt(original_prompt: str, retry_feedback: str) -> str:
305
+ """Append formatting-error instructions to the original complete prompt."""
306
+ return original_prompt + retry_feedback