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.
- fastsft/__init__.py +3 -0
- fastsft/constants.py +36 -0
- fastsft/data/__init__.py +0 -0
- fastsft/data/config.py +34 -0
- fastsft/data/constants.py +33 -0
- fastsft/data/prompt_generator.py +97 -0
- fastsft/data/refiner.py +72 -0
- fastsft/data/response_generator.py +21 -0
- fastsft/data/viewer.py +93 -0
- fastsft/device.py +53 -0
- fastsft/eval/__init__.py +7 -0
- fastsft/eval/config.py +40 -0
- fastsft/eval/constants.py +53 -0
- fastsft/eval/embeddings.py +44 -0
- fastsft/eval/evaluator.py +243 -0
- fastsft/eval/inference.py +129 -0
- fastsft/eval/inference_viewer.py +83 -0
- fastsft/eval/prompt_set.py +120 -0
- fastsft/eval/results.py +221 -0
- fastsft/eval/results_viewer.py +153 -0
- fastsft/eval/run.py +252 -0
- fastsft/findings.py +21 -0
- fastsft/findings_view.py +30 -0
- fastsft/helper.py +121 -0
- fastsft/main.py +344 -0
- fastsft/model/__init__.py +0 -0
- fastsft/model/_logging.py +21 -0
- fastsft/model/base.py +154 -0
- fastsft/model/constants.py +41 -0
- fastsft/model/guide.py +53 -0
- fastsft/model/judge.py +123 -0
- fastsft/pipeline.py +105 -0
- fastsft/progress.py +39 -0
- fastsft/py.typed +0 -0
- fastsft/stages/__init__.py +0 -0
- fastsft/stages/base.py +53 -0
- fastsft/stages/constants.py +11 -0
- fastsft/stages/data_formatter.py +77 -0
- fastsft/stages/data_generator.py +154 -0
- fastsft/stages/fine_tuner.py +161 -0
- fastsft/training/__init__.py +0 -0
- fastsft/training/config.py +67 -0
- fastsft/training/constants.py +59 -0
- fastsft/training/heuristic.py +300 -0
- fastsft/training/local_trainer.py +56 -0
- fastsft/training/modal_app.py +79 -0
- fastsft/training/stats.py +246 -0
- fastsft/training/stats_viewer.py +181 -0
- fastsft/training/trainer.py +234 -0
- fastsft/validation_checks.py +62 -0
- fastsft/warnings_filter.py +48 -0
- fastsft-0.1.0.dist-info/METADATA +544 -0
- fastsft-0.1.0.dist-info/RECORD +56 -0
- fastsft-0.1.0.dist-info/WHEEL +4 -0
- fastsft-0.1.0.dist-info/entry_points.txt +3 -0
- fastsft-0.1.0.dist-info/licenses/LICENSE +21 -0
fastsft/__init__.py
ADDED
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"
|
fastsft/data/__init__.py
ADDED
|
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
|
+
)
|
fastsft/data/refiner.py
ADDED
|
@@ -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()
|
fastsft/eval/__init__.py
ADDED
|
@@ -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)]
|