fastsft 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.
Files changed (56) hide show
  1. fastsft/__init__.py +3 -0
  2. fastsft/constants.py +36 -0
  3. fastsft/data/__init__.py +0 -0
  4. fastsft/data/config.py +34 -0
  5. fastsft/data/constants.py +33 -0
  6. fastsft/data/prompt_generator.py +97 -0
  7. fastsft/data/refiner.py +72 -0
  8. fastsft/data/response_generator.py +21 -0
  9. fastsft/data/viewer.py +93 -0
  10. fastsft/device.py +53 -0
  11. fastsft/eval/__init__.py +7 -0
  12. fastsft/eval/config.py +40 -0
  13. fastsft/eval/constants.py +53 -0
  14. fastsft/eval/embeddings.py +44 -0
  15. fastsft/eval/evaluator.py +243 -0
  16. fastsft/eval/inference.py +129 -0
  17. fastsft/eval/inference_viewer.py +83 -0
  18. fastsft/eval/prompt_set.py +120 -0
  19. fastsft/eval/results.py +221 -0
  20. fastsft/eval/results_viewer.py +153 -0
  21. fastsft/eval/run.py +252 -0
  22. fastsft/findings.py +21 -0
  23. fastsft/findings_view.py +30 -0
  24. fastsft/helper.py +121 -0
  25. fastsft/main.py +344 -0
  26. fastsft/model/__init__.py +0 -0
  27. fastsft/model/_logging.py +21 -0
  28. fastsft/model/base.py +154 -0
  29. fastsft/model/constants.py +41 -0
  30. fastsft/model/guide.py +53 -0
  31. fastsft/model/judge.py +123 -0
  32. fastsft/pipeline.py +105 -0
  33. fastsft/progress.py +39 -0
  34. fastsft/py.typed +0 -0
  35. fastsft/stages/__init__.py +0 -0
  36. fastsft/stages/base.py +53 -0
  37. fastsft/stages/constants.py +11 -0
  38. fastsft/stages/data_formatter.py +77 -0
  39. fastsft/stages/data_generator.py +154 -0
  40. fastsft/stages/fine_tuner.py +161 -0
  41. fastsft/training/__init__.py +0 -0
  42. fastsft/training/config.py +67 -0
  43. fastsft/training/constants.py +59 -0
  44. fastsft/training/heuristic.py +300 -0
  45. fastsft/training/local_trainer.py +56 -0
  46. fastsft/training/modal_app.py +79 -0
  47. fastsft/training/stats.py +246 -0
  48. fastsft/training/stats_viewer.py +181 -0
  49. fastsft/training/trainer.py +234 -0
  50. fastsft/validation_checks.py +62 -0
  51. fastsft/warnings_filter.py +48 -0
  52. fastsft-0.1.0.dist-info/METADATA +544 -0
  53. fastsft-0.1.0.dist-info/RECORD +56 -0
  54. fastsft-0.1.0.dist-info/WHEEL +4 -0
  55. fastsft-0.1.0.dist-info/entry_points.txt +3 -0
  56. fastsft-0.1.0.dist-info/licenses/LICENSE +21 -0
fastsft/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """FastSFT: distill a large LLM into a small one via synthetic-data generation, quality filtering, and LoRA/QLoRA fine-tuning."""
2
+
3
+ __version__ = "0.1.0"
fastsft/constants.py ADDED
@@ -0,0 +1,36 @@
1
+ """Constants used directly by the pipeline entry points (main.py, pipeline.py).
2
+
3
+ Model-mechanics constants live in model/constants.py and data-generation
4
+ tuning in data/constants.py.
5
+ """
6
+
7
+ # Base directory holding `datasets/` and `modelsets/`. Overridable via the
8
+ # OUTPUT_DIR_ENV_VAR env var or the --output-dir CLI flag, so an installed
9
+ # fastsft can write outside the current directory (resolved by helper.py's
10
+ # datasets_dir()/modelsets_dir()). Empty/unset means the current directory, so
11
+ # running from the repo is unchanged.
12
+ OUTPUT_DIR_ENV_VAR = "FASTSFT_OUTPUT_DIR"
13
+ DEFAULT_OUTPUT_DIR = "datasets"
14
+ RAW_OUTPUT_SUBDIR = "raw"
15
+ FORMATTED_OUTPUT_SUBDIR = "formatted"
16
+ MODELSETS_OUTPUT_DIR = "modelsets"
17
+ # One evalsets/<run_id>/ folder per eval run: eval_prompts, eval_answers.json, eval_results.json.
18
+ EVALSETS_OUTPUT_DIR = "evalsets"
19
+ # Subfolder name for the eval prompt set within an evalsets run dir (see eval/prompt_set.py).
20
+ EVAL_PROMPTS_SUBDIR = "eval_prompts"
21
+ RUN_TIMESTAMP_FORMAT = "%Y%m%d_%H%M%S"
22
+
23
+ # Sidecar (in a raw dataset run dir) recording the teacher that produced the
24
+ # training data, so evaluation can reconstruct the true parent reference.
25
+ TRAINING_METADATA_FILENAME = "training_metadata.json"
26
+
27
+ DEFAULT_PARENT_MODEL = "meta-llama/llama-3.3-70b-instruct"
28
+
29
+ # Different family from the parent, to avoid self-preference bias.
30
+ DEFAULT_JUDGE_MODEL = "deepseek/deepseek-chat"
31
+
32
+ # Must support tool calls (structured output).
33
+ DEFAULT_GUIDE_MODEL = "qwen/qwen-2.5-7b-instruct"
34
+
35
+ # Hugging Face repo id, not an OpenRouter model id.
36
+ DEFAULT_CHILD_MODEL_ID = "Qwen/Qwen2.5-0.5B-Instruct"
File without changes
fastsft/data/config.py ADDED
@@ -0,0 +1,34 @@
1
+ """Configuration for DataGenerator's generation-side models and tuning."""
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+ from fastsft.constants import (
6
+ DEFAULT_GUIDE_MODEL,
7
+ DEFAULT_JUDGE_MODEL,
8
+ DEFAULT_PARENT_MODEL,
9
+ )
10
+ from fastsft.data.constants import (
11
+ BREADTH_EXPONENT,
12
+ DEFAULT_NUM_SAMPLES,
13
+ DEFAULT_PARENT_TEMPERATURE,
14
+ )
15
+ from fastsft.model.constants import DEFAULT_MAX_TOKENS, DEFAULT_SCORE_THRESHOLD
16
+
17
+
18
+ @dataclass
19
+ class ParentGenerationConfig:
20
+ """Tuning for the parent model's own generation calls."""
21
+
22
+ temperature: float = DEFAULT_PARENT_TEMPERATURE
23
+ max_tokens: int = DEFAULT_MAX_TOKENS
24
+
25
+
26
+ @dataclass
27
+ class DataGenerationConfig:
28
+ guide_model: str = DEFAULT_GUIDE_MODEL
29
+ parent_model: str = DEFAULT_PARENT_MODEL
30
+ judge_model: str = DEFAULT_JUDGE_MODEL
31
+ num_samples: int = DEFAULT_NUM_SAMPLES
32
+ breadth_exponent: float = BREADTH_EXPONENT
33
+ score_threshold: float = DEFAULT_SCORE_THRESHOLD
34
+ parent_generation: ParentGenerationConfig = field(default_factory=ParentGenerationConfig)
@@ -0,0 +1,33 @@
1
+ """Constants for the data package (synthetic-data generation and refinement)."""
2
+
3
+ # Default dataset size when the caller doesn't specify.
4
+ DEFAULT_NUM_SAMPLES = 100
5
+
6
+ # Default sampling temperature for the parent model's generations -- high, to
7
+ # keep the synthetic dataset varied. (The Model base class's own default is
8
+ # separate; the model layer can't depend on data/ without inverting the layers.)
9
+ DEFAULT_PARENT_TEMPERATURE = 0.9
10
+
11
+ # Breadth (distinct seed topics) = ceil(N ** BREADTH_EXPONENT); >0.5 favors
12
+ # breadth over depth (breadth:depth = N^exp : N^(1-exp)). Set high (0.85) to
13
+ # actively favor topic diversity over per-topic complexity depth -- e.g. for
14
+ # N=200: ~83 topics x ~2-3 complexity-varied instructions each.
15
+ BREADTH_EXPONENT = 0.85
16
+
17
+ # Extra guide-output budget per seed, so many-seed requests don't truncate.
18
+ GUIDE_TOKENS_PER_SEED = 64
19
+
20
+ # Max passes to top up under-delivered prompt generations to num_samples.
21
+ MAX_PROMPT_ATTEMPTS = 5
22
+
23
+ MAX_REFINE_ITERATIONS = 5
24
+
25
+ # System prompt for the parent model when generating user instructions.
26
+ PROMPT_GENERATOR_INSTRUCTION = (
27
+ "You generate user questions for a synthetic dataset. Given a seed question "
28
+ "and a requested count, produce that many DISTINCT user questions on the same "
29
+ "topic, spanning a range of complexity -- the simplest a plain question, the "
30
+ "most complex adding multiple constraints, sub-parts, or multi-step reasoning. "
31
+ "Each must be a natural, concrete question a real user would type. None may "
32
+ "mention answer style, persona, tone, or format."
33
+ )
@@ -0,0 +1,97 @@
1
+ """Generates the dataset's user instructions directly via the parent model.
2
+
3
+ Seeds provide breadth (distinct topics); each seed is expanded into depth
4
+ (complexity-varied) instructions.
5
+ """
6
+
7
+ import math
8
+
9
+ from pydantic import BaseModel
10
+
11
+ from fastsft.data.constants import (
12
+ BREADTH_EXPONENT,
13
+ MAX_PROMPT_ATTEMPTS,
14
+ PROMPT_GENERATOR_INSTRUCTION,
15
+ )
16
+ from fastsft.model.base import Model
17
+
18
+
19
+ def seed_count(num_samples: int, breadth_exponent: float = BREADTH_EXPONENT) -> int:
20
+ """Breadth: number of distinct seed topics for `num_samples`
21
+ (ceil(N ** breadth_exponent), clamped to [1, num_samples])."""
22
+ # math.pow (not `**`) so the result types as float, not Any -- `**` on two
23
+ # numbers is typed Any in typeshed (a negative base + fractional exponent
24
+ # can return complex), which isn't a real concern for this always-positive
25
+ # breadth calculation.
26
+ return max(1, min(num_samples, math.ceil(math.pow(num_samples, breadth_exponent))))
27
+
28
+
29
+ class GeneratedPrompts(BaseModel):
30
+ prompts: list[str]
31
+
32
+
33
+ class PromptGenerator:
34
+ """Expands seed topics into exactly `num_samples` user instructions: each
35
+ seed is turned into its allotted number of questions spanning simple to
36
+ complex.
37
+ """
38
+
39
+ def __init__(self, model: Model, num_samples: int):
40
+ self._model = model
41
+ self._num_samples = num_samples
42
+
43
+ def generate(self, seeds: list[str]) -> list[str]:
44
+ """Returns exactly `num_samples` instructions spread across `seeds`."""
45
+ if not seeds:
46
+ raise ValueError("PromptGenerator.generate() requires at least one seed.")
47
+
48
+ # Each row is capped at its requested count, so a pass yields at most
49
+ # the deficit -- top up whatever a model under-delivers.
50
+ prompts: list[str] = []
51
+ for _ in range(MAX_PROMPT_ATTEMPTS):
52
+ deficit = self._num_samples - len(prompts)
53
+ if deficit == 0:
54
+ break
55
+ prompts.extend(self._generate(self._allocate(seeds, deficit)))
56
+ else:
57
+ raise RuntimeError(
58
+ f"PromptGenerator produced {len(prompts)}/{self._num_samples} "
59
+ f"instructions after {MAX_PROMPT_ATTEMPTS} attempts."
60
+ )
61
+ return prompts
62
+
63
+ def _generate(self, allocation: list[tuple[str, int]]) -> list[str]:
64
+ """Generates the capped prompts for one (seed, count) allocation."""
65
+ data = [
66
+ {"instruction": self._row_prompt(seed, count), "count": count}
67
+ for seed, count in allocation
68
+ ]
69
+ distiset = self._model.run_pipeline(
70
+ data,
71
+ PROMPT_GENERATOR_INSTRUCTION,
72
+ structured_output={"schema": GeneratedPrompts, "format": "json"},
73
+ name="prompt-generation",
74
+ )
75
+
76
+ prompts: list[str] = []
77
+ for row in distiset["default"]["train"]:
78
+ generation = self._model.assert_structured_output(row["generation"])
79
+ parsed = GeneratedPrompts.model_validate_json(generation)
80
+ prompts.extend(parsed.prompts[: row["count"]])
81
+ return prompts
82
+
83
+ def _allocate(self, seeds: list[str], n: int) -> list[tuple[str, int]]:
84
+ """Distributes `n` instructions as evenly as possible across `seeds`,
85
+ dropping any seed that would get zero."""
86
+ base, extra = divmod(n, len(seeds))
87
+ counts = (
88
+ (seed, base + (1 if i < extra else 0)) for i, seed in enumerate(seeds)
89
+ )
90
+ return [(seed, count) for seed, count in counts if count > 0]
91
+
92
+ def _row_prompt(self, seed: str, count: int) -> str:
93
+ return (
94
+ f"Seed question: {seed}\n\n"
95
+ f"Produce exactly {count} distinct user questions on this same topic, "
96
+ f"ordered from the simplest to the most complex."
97
+ )
@@ -0,0 +1,72 @@
1
+ """Refines a Distiset by regenerating samples that fail the judge's quality bar."""
2
+
3
+
4
+ from datasets import Dataset, concatenate_datasets
5
+ from distilabel.distiset import Distiset
6
+
7
+ from fastsft.data.constants import MAX_REFINE_ITERATIONS
8
+ from fastsft.data.response_generator import ResponseGenerator
9
+ from fastsft.helper import convert_to_distiset
10
+ from fastsft.model.base import Model
11
+ from fastsft.model.constants import DEFAULT_SCORE_THRESHOLD
12
+ from fastsft.model.judge import Judge
13
+
14
+
15
+ class DataRefiner:
16
+ """Re-answers low-scoring samples, keeping their instructions and the
17
+ count constant, up to MAX_REFINE_ITERATIONS times or until nothing fails.
18
+ """
19
+
20
+ def __init__(self, parent_model: Model, judge_model: Judge):
21
+ self._parent_model = parent_model
22
+ self._judge_model = judge_model
23
+
24
+ def refine(
25
+ self, distiset: Distiset, threshold: float = DEFAULT_SCORE_THRESHOLD
26
+ ) -> Distiset:
27
+ train = distiset["default"]["train"]
28
+
29
+ # Score the initial batch once; thereafter score only fresh rows.
30
+ scores = self._score(train)
31
+ for _ in range(MAX_REFINE_ITERATIONS):
32
+ if self._judge_model.failed_sample_count(scores, threshold=threshold) == 0:
33
+ break
34
+
35
+ failed_instructions = self._failed_instructions(train, scores, threshold)
36
+ train, scores = self._drop_failed(train, scores, threshold)
37
+ replacements = self._regenerate(failed_instructions)
38
+ train = concatenate_datasets([train, replacements])
39
+ scores = scores + self._score(replacements)
40
+
41
+ return convert_to_distiset(train)
42
+
43
+ def _score(self, train: Dataset) -> list[float]:
44
+ """Scores every row in `train`, returned aligned to row order."""
45
+ samples = {str(i): row["generation"] for i, row in enumerate(train)}
46
+ scores_by_id = self._judge_model.score_samples(samples)
47
+ return [scores_by_id[str(i)] for i in range(len(train))]
48
+
49
+ def _failed_instructions(
50
+ self, train: Dataset, scores: list[float], threshold: float
51
+ ) -> list[str]:
52
+ """Instructions of the rows scoring below `threshold`."""
53
+ return [
54
+ train[i]["instruction"]
55
+ for i in range(len(train))
56
+ if scores[i] < threshold
57
+ ]
58
+
59
+ def _drop_failed(
60
+ self, train: Dataset, scores: list[float], threshold: float
61
+ ) -> tuple[Dataset, list[float]]:
62
+ """Returns `train` and `scores` with every row scoring below
63
+ `threshold` removed, kept in alignment."""
64
+ keep_indices = [i for i in range(len(train)) if scores[i] >= threshold]
65
+ return train.select(keep_indices), [scores[i] for i in keep_indices]
66
+
67
+ def _regenerate(self, instructions: list[str]) -> Dataset:
68
+ """Generates fresh answers for `instructions`."""
69
+ replacements = ResponseGenerator(model=self._parent_model).generate(
70
+ instructions
71
+ )
72
+ return replacements["default"]["train"]
@@ -0,0 +1,21 @@
1
+ """Generates the assistant answers for a set of instructions."""
2
+
3
+
4
+ from distilabel.distiset import Distiset
5
+
6
+ from fastsft.model.base import Model
7
+
8
+
9
+ class ResponseGenerator:
10
+ """Generates one styled answer per instruction via the parent model,
11
+ one API call per instruction (not the `n` param, which many providers ignore).
12
+ """
13
+
14
+ def __init__(self, model: Model | None = None):
15
+ self._model = model or Model()
16
+
17
+ def generate(self, instructions: list[str]) -> Distiset:
18
+ data = [{"instruction": instruction} for instruction in instructions]
19
+ return self._model.run_pipeline(
20
+ data, self._model.get_instruction(), name="response-generation"
21
+ )
fastsft/data/viewer.py ADDED
@@ -0,0 +1,93 @@
1
+ """View samples from a saved synthetic dataset in the terminal."""
2
+
3
+ import fastsft.warnings_filter # noqa: F401
4
+
5
+ import argparse
6
+ import os
7
+
8
+ from rich.console import Console
9
+ from rich.panel import Panel
10
+
11
+ from fastsft.constants import (
12
+ DEFAULT_OUTPUT_DIR,
13
+ FORMATTED_OUTPUT_SUBDIR,
14
+ RAW_OUTPUT_SUBDIR,
15
+ )
16
+ from fastsft.helper import datasets_dir, latest_run_path, load_data
17
+
18
+ console = Console()
19
+
20
+ _ROLE_COLORS = {"user": "cyan", "assistant": "magenta"}
21
+
22
+
23
+ def _format_message(message: dict) -> str:
24
+ role = message.get("role", "?")
25
+ color = _ROLE_COLORS.get(role, "white")
26
+ return f"[bold {color}]{role}[/bold {color}]: {message.get('content', '')}"
27
+
28
+
29
+ class DataViewer:
30
+ """Loads a saved `Distiset` and previews samples."""
31
+
32
+ def __init__(self, path: str | None = None, kind: str = "raw"):
33
+ if path is None:
34
+ subdir = FORMATTED_OUTPUT_SUBDIR if kind == "formatted" else RAW_OUTPUT_SUBDIR
35
+ path = latest_run_path(os.path.join(datasets_dir(), subdir))
36
+ distiset = load_data(path)
37
+ assert distiset is not None, f"No dataset found at '{path}'."
38
+ self.dataset = distiset["default"]["train"]
39
+
40
+ def raw_samples(self, n: int = 5) -> None:
41
+ """Prints the first `n` raw samples' `messages` as generated."""
42
+ for i, row in enumerate(self.dataset.select(range(min(n, len(self.dataset))))):
43
+ messages = row.get("messages", [])
44
+ body = "\n\n".join(_format_message(m) for m in messages)
45
+ console.print(
46
+ Panel(
47
+ body,
48
+ title=f"[bold cyan][{i}][/bold cyan]",
49
+ border_style="cyan",
50
+ expand=False,
51
+ )
52
+ )
53
+
54
+ def formatted_samples(self, n: int = 5) -> None:
55
+ """Prints the first `n` samples' `text` column."""
56
+ for i, row in enumerate(self.dataset.select(range(min(n, len(self.dataset))))):
57
+ console.print(
58
+ Panel(
59
+ row.get("text", ""),
60
+ title=f"[bold cyan][{i}][/bold cyan]",
61
+ border_style="cyan",
62
+ expand=False,
63
+ )
64
+ )
65
+
66
+
67
+ def main() -> None:
68
+ parser = argparse.ArgumentParser(description="Preview samples from a saved distilabel dataset.")
69
+ parser.add_argument(
70
+ "--input-path",
71
+ default=None,
72
+ help=f"Directory the dataset was saved to (default: latest run under "
73
+ f"{DEFAULT_OUTPUT_DIR}/{RAW_OUTPUT_SUBDIR}/ or {DEFAULT_OUTPUT_DIR}/"
74
+ f"{FORMATTED_OUTPUT_SUBDIR}/, depending on --formatted).",
75
+ )
76
+ parser.add_argument("--num-samples", type=int, default=5, help="Number of samples to display.")
77
+ parser.add_argument(
78
+ "--formatted",
79
+ action="store_true",
80
+ help="Show the DataFormatter-rendered 'text' column instead of raw 'messages'.",
81
+ )
82
+ args = parser.parse_args()
83
+
84
+ kind = "formatted" if args.formatted else "raw"
85
+ viewer = DataViewer(args.input_path, kind=kind)
86
+ if args.formatted:
87
+ viewer.formatted_samples(args.num_samples)
88
+ else:
89
+ viewer.raw_samples(args.num_samples)
90
+
91
+
92
+ if __name__ == "__main__":
93
+ main()
fastsft/device.py ADDED
@@ -0,0 +1,53 @@
1
+ """Local torch runtime detection, shared by training (training/local_trainer.py,
2
+ stages/fine_tuner.py) and evaluation (eval/inference.py): which accelerator this
3
+ machine has, and the dtype to load models in on it.
4
+
5
+ Torch is imported lazily inside each function, so this module stays importable
6
+ without torch (it lives only in the local-training / evaluation optional extras).
7
+ The `TYPE_CHECKING` import below is erased at runtime -- it exists only so
8
+ `dtype_for_device`'s return type is real, not `Any`.
9
+
10
+ Also runnable directly to report what this machine offers:
11
+
12
+ uv run python -m fastsft.device
13
+ """
14
+
15
+ from typing import TYPE_CHECKING
16
+
17
+ if TYPE_CHECKING:
18
+ import torch
19
+
20
+
21
+ def detect_device() -> str:
22
+ """Detect 'cuda', 'mps', or 'cpu' on this machine."""
23
+ import torch
24
+
25
+ if torch.cuda.is_available():
26
+ return "cuda"
27
+ if torch.backends.mps.is_available():
28
+ return "mps"
29
+ return "cpu"
30
+
31
+
32
+ def dtype_for_device(device: str) -> "torch.dtype":
33
+ """Return torch dtype for device: bf16 on accelerators (cuda/mps), fp32 on cpu."""
34
+ import torch
35
+
36
+ return torch.bfloat16 if device in ("cuda", "mps") else torch.float32
37
+
38
+
39
+ def main() -> None:
40
+ """Report detected device, dtype, and torch version."""
41
+ device = detect_device()
42
+ print(f"Device: {device}")
43
+ print(f"Dtype: {dtype_for_device(device)}")
44
+
45
+ import torch
46
+
47
+ print(f"Torch: {torch.__version__}")
48
+ if device == "cuda":
49
+ print(f"GPU: {torch.cuda.get_device_name(0)}")
50
+
51
+
52
+ if __name__ == "__main__":
53
+ main()
@@ -0,0 +1,7 @@
1
+ """Extrinsic, judge-based evaluation of a fine-tuned child adapter.
2
+
3
+ Standalone (not a DistillationPipeline stage): runs *after* training against a
4
+ saved adapter, loading the parent (via OpenRouter), the tuned child, and the
5
+ untuned child at once. Entry point: `python -m fastsft.eval.run [adapter_dir]`;
6
+ view a finished run with `python -m fastsft.eval.results_viewer`.
7
+ """
fastsft/eval/config.py ADDED
@@ -0,0 +1,40 @@
1
+ """Configuration for the evaluation run: which adapter to score, which parent/
2
+ judge models to score it against, and the eval-set/inference tuning knobs."""
3
+
4
+ from dataclasses import dataclass
5
+
6
+ from fastsft.constants import DEFAULT_JUDGE_MODEL, DEFAULT_PARENT_MODEL
7
+ from fastsft.data.constants import DEFAULT_PARENT_TEMPERATURE
8
+ from fastsft.eval.constants import (
9
+ DEFAULT_EMBEDDING_MODEL,
10
+ DEFAULT_INFERENCE_BATCH_SIZE,
11
+ DEFAULT_MAX_NEW_TOKENS,
12
+ DEFAULT_NUM_EVAL_PROMPTS,
13
+ DEFAULT_SWAP_POSITIONS,
14
+ )
15
+ from fastsft.model.constants import DEFAULT_MAX_TOKENS
16
+
17
+
18
+ @dataclass
19
+ class EvalConfig:
20
+ """A single evaluation run against one saved adapter directory."""
21
+
22
+ adapter_dir: str
23
+ # This eval run's own id (names its evalsets_dir()/<run_id> folder), distinct from adapter_dir's.
24
+ run_id: str
25
+ parent_model: str = DEFAULT_PARENT_MODEL
26
+ judge_model: str = DEFAULT_JUDGE_MODEL
27
+ embedding_model: str = DEFAULT_EMBEDDING_MODEL
28
+ num_eval_prompts: int = DEFAULT_NUM_EVAL_PROMPTS
29
+ max_new_tokens: int = DEFAULT_MAX_NEW_TOKENS
30
+ inference_batch_size: int = DEFAULT_INFERENCE_BATCH_SIZE
31
+ swap_positions: bool = DEFAULT_SWAP_POSITIONS
32
+ # The parent's style system prompt, inferred from the run's training metadata
33
+ # by default (eval/run.py) to reconstruct the styled teacher; "" means no
34
+ # system prompt (e.g. a bring-your-own dataset with no metadata).
35
+ parent_instruction: str = ""
36
+ # The parent's generation recipe, also inferred from training metadata so the
37
+ # reference answers like the actual teacher (same length/sampling the data was
38
+ # generated with). Falls back to the pipeline's training defaults.
39
+ parent_max_tokens: int = DEFAULT_MAX_TOKENS
40
+ parent_temperature: float = DEFAULT_PARENT_TEMPERATURE
@@ -0,0 +1,53 @@
1
+ """Constants for the eval package (extrinsic judge-based evaluation)."""
2
+
3
+ # Default size of the held-out eval prompt set. Smaller than a training run --
4
+ # every prompt costs three generations (parent, tuned, untuned) plus judging.
5
+ DEFAULT_NUM_EVAL_PROMPTS = 50
6
+
7
+ # Local Hugging Face sentence-embedding model for parent-similarity scoring.
8
+ # Small and CPU-friendly; runs via sentence-transformers (evaluation extra).
9
+ DEFAULT_EMBEDDING_MODEL = "sentence-transformers/all-MiniLM-L6-v2"
10
+
11
+ # Child-inference generation budget (new tokens per answer) and how many
12
+ # prompts to feed the model at once.
13
+ DEFAULT_MAX_NEW_TOKENS = 512
14
+ DEFAULT_INFERENCE_BATCH_SIZE = 8
15
+
16
+ # Compare each pair twice with A/B swapped to cancel the judge's position bias.
17
+ DEFAULT_SWAP_POSITIONS = True
18
+
19
+ # Eval results live in evalsets_dir()/<run_id>/, one folder per eval/run.py invocation.
20
+ EVAL_RESULTS_FILENAME = "eval_results.json"
21
+
22
+ # Raw per-prompt answers, written right after generation so a judging failure doesn't lose them.
23
+ EVAL_ANSWERS_FILENAME = "eval_answers.json"
24
+
25
+ # Rubric (judge system prompt) for the pairwise quality comparison. Deliberately
26
+ # generic -- it drives both tuned-vs-untuned and tuned-vs-parent comparisons.
27
+ COMPARISON_JUDGE_INSTRUCTION = (
28
+ "You are comparing two AI assistant responses, A and B, to the same user "
29
+ "question. Decide which response is higher quality overall -- more helpful, "
30
+ "accurate, relevant, and clearly written. Judge only on quality: ignore the "
31
+ "order in which the responses are presented, and do not prefer a response "
32
+ "merely for being longer. Respond with a single verdict: \"A\" if A is "
33
+ "better, \"B\" if B is better, or \"tie\" if they are of genuinely equal "
34
+ "quality."
35
+ )
36
+
37
+ # Rubric for the parent-likeness comparison: which candidate answer is more like
38
+ # the reference (parent) in STYLE -- tone, voice, structure, formatting,
39
+ # verbosity -- regardless of which is more correct. This is the metric aligned
40
+ # with the phase-0 distillation objective (voice/tone transfer) that the
41
+ # generic-quality rubric above doesn't capture; it excludes correctness (the
42
+ # quality metric's job) so the two stay orthogonal.
43
+ STYLE_JUDGE_INSTRUCTION = (
44
+ "You are given a user question, a REFERENCE response, and two candidate "
45
+ "responses, A and B. Decide which candidate more closely matches the "
46
+ "REFERENCE's STYLE -- its tone, voice, structure, formatting, verbosity, "
47
+ "and overall approach -- regardless of which candidate is more correct, "
48
+ "helpful, or better written. You are judging stylistic resemblance to the "
49
+ "reference, not quality. Ignore the order in which the candidates are "
50
+ "presented. Respond with a single verdict: \"A\" if A is more like the "
51
+ "reference, \"B\" if B is more like the reference, or \"tie\" if they "
52
+ "resemble it equally."
53
+ )
@@ -0,0 +1,44 @@
1
+ """Local sentence embeddings for the distillation-fidelity metric: how close the
2
+ child's answers sit to the parent's in embedding space.
3
+
4
+ Uses sentence-transformers (evaluation extra); the model runs locally, so no
5
+ embedding API or key is involved.
6
+ """
7
+
8
+ from functools import lru_cache
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ from fastsft.eval.constants import DEFAULT_EMBEDDING_MODEL
12
+
13
+ if TYPE_CHECKING:
14
+ from sentence_transformers import SentenceTransformer
15
+
16
+
17
+ @lru_cache(maxsize=2)
18
+ def _load_model(model_id: str) -> "SentenceTransformer":
19
+ """Loads and caches a SentenceTransformer (heavy import kept local)."""
20
+ from sentence_transformers import SentenceTransformer
21
+
22
+ return SentenceTransformer(model_id)
23
+
24
+
25
+ def embed(texts: list[str], model_id: str = DEFAULT_EMBEDDING_MODEL) -> Any:
26
+ """L2-normalized embeddings for `texts` (one row each)."""
27
+ return _load_model(model_id).encode(list(texts), normalize_embeddings=True)
28
+
29
+
30
+ def pairwise_similarities(
31
+ a_texts: list[str], b_texts: list[str], model_id: str = DEFAULT_EMBEDDING_MODEL
32
+ ) -> list[float]:
33
+ """Cosine similarity of each aligned (a, b) pair. Embeddings are normalized,
34
+ so cosine is a row-wise dot product."""
35
+ if len(a_texts) != len(b_texts):
36
+ raise ValueError(
37
+ f"pairwise_similarities needs aligned lists, got {len(a_texts)} vs "
38
+ f"{len(b_texts)}."
39
+ )
40
+ if not a_texts:
41
+ return []
42
+ a = embed(a_texts, model_id)
43
+ b = embed(b_texts, model_id)
44
+ return [float(sim) for sim in (a * b).sum(axis=1)]