Repository navigation
Expand file tree
/
Copy pathconfig.py
More file actions
2334 lines (2113 loc) · 112 KB
/
Copy pathconfig.py
File metadata and controls
2334 lines (2113 loc) · 112 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import logging
import math
import os
import re
import warnings
from typing import Annotated, Any, Dict, List, Literal, Optional, Tuple
from urllib.parse import urlsplit
import yaml
from pydantic import BaseModel, ConfigDict, Field, FiniteFloat, ValidationError, field_validator, model_validator
from ._artifact_paths import _validate_config_directory, _validate_config_layout
from ._strict_json import dumps_strict
logger = logging.getLogger("forgelm.config")
# Single source of truth for the release that drops the currently-deprecated
# YAML fields (`lora.use_dora`, `lora.use_rslora`, `training.sample_packing`).
#
# Removing a YAML field is a MAJOR change per docs/standards/release.md
# ("What constitutes 'breaking'" — "Removing a YAML field | Yes"), so the
# removal cannot land on a MINOR bump; v1.0.0 is the earliest legal target.
#
# Every runtime deprecation message, field `description=`, docs claim and
# `config_template.yaml` comment must be built from / agree with this
# constant. It was previously a hardcoded literal duplicated across ~20
# sites and rotted twice (v0.9.0 -> v0.10.0 -> v1.0.0) because nothing deprecation-target-ok
# cross-checked them; `tools/check_deprecation_targets.py` now reads this
# constant as canonical and fails CI on any divergent claim.
# (The `deprecation-target-ok` marker above exempts the historical
# narration from the guard: those two versions are a record of what the
# promise used to say, not a competing claim about what it says now.
# Without the marker, a future edit that moves a field name within the
# guard's line window would turn this comment into a CI failure.)
DEPRECATION_REMOVAL_VERSION = "v1.0.0"
# The one regex the revision-pin feature owes docs/standards/regex.md.
#
# Anchored, fixed-width `{40}`, single character class, no alternation and no
# unbounded quantifier — trivially linear, with nothing for an engine to
# backtrack through (rules 3, 4 and 7). Both cases are accepted: a SHA copied
# out of a Hub URL is conventionally lowercase, but an uppercase transcription
# resolves to the identical commit upstream and must not be mis-reported to the
# operator as a moving ref. Malformed-literal checks are plain `str`
# predicates in `_validate_revision_literal`, deliberately not a second pattern.
_SHA40_REVISION = re.compile(r"^[0-9a-fA-F]{40}$")
# Upper bound on a revision literal. Git itself caps no ref length, but every
# filesystem the HF Hub cache is written to caps a single path component at
# 255 bytes, so a longer literal cannot name a snapshot that could be staged.
_REVISION_MAX_LENGTH = 255
def _validate_revision_literal(value: Optional[str], field_path: str) -> Optional[str]:
"""Reject malformed revision literals; warn on refs that do not actually pin.
Shared by every ``revision`` field in this schema. Structural defects
raise, because a typo'd pin must never reach a loader. A well-formed but
*symbolic* ref (``main``, ``v1.0``, ``refs/pr/7``) only warns: operators
legitimately evaluate branches and PR refs, and refusing them outright
needs an enforcement policy this layer deliberately does not own.
Args:
value: Raw literal from YAML, or ``None`` when the field is unset.
field_path: Dotted YAML path, quoted verbatim in the message so the
operator can find the offending line without guessing.
Returns:
The literal unchanged. It is never normalised or case-folded — the
Annex IV manifest records what the operator asked for verbatim beside
the SHA that actually resolved, and a silently rewritten request makes
that pair a weaker record than it looks.
Raises:
ValueError: The literal cannot name an HF Hub revision.
"""
if value is None:
return None
if not value:
raise ValueError(
f"{field_path} is set to an empty string, which names no revision. "
"Omit the field entirely to load the repo's default branch."
)
if any(character.isspace() for character in value):
raise ValueError(
f"{field_path}={value!r} contains whitespace. A revision is a 40-hex commit SHA, "
"a branch, or a tag — none of which may contain spaces."
)
# The value is withheld from the next two messages on purpose: echoing a
# control-character or multi-kilobyte literal into a log line hands the
# config author control of the log format (logging-observability.md).
if any(character < " " or character == "\x7f" for character in value):
raise ValueError(
f"{field_path} contains a control character. A revision is a 40-hex commit SHA, a branch, or a tag."
)
if value.startswith("-"):
raise ValueError(
f"{field_path} starts with '-', which no HF Hub revision does and which downstream "
"tooling reads as a command-line flag. Check for a stray dash or a mangled paste."
)
if len(value) > _REVISION_MAX_LENGTH:
raise ValueError(
f"{field_path} is {len(value)} characters long; an HF Hub revision is at most "
f"{_REVISION_MAX_LENGTH}. Check for a mangled paste."
)
if not _SHA40_REVISION.match(value):
# The tag trap: the Hub accepts this and the operator believes they are
# pinned, but a branch or tag can be repointed upstream at any time.
logger.warning(
"%s=%r is a moving ref, not a pin: the branch or tag it names can be repointed "
"upstream at any time, so two runs of the same config can load different bytes. "
"It is recorded verbatim in the Annex IV manifest beside the commit SHA that actually "
"resolved, so the artefact stays honest either way — but use the 40-hex commit SHA if "
"the run needs to be reproducible.",
field_path,
value,
)
return value
class MoeConfig(BaseModel):
"""MoE-specific fine-tuning configuration."""
model_config = ConfigDict(extra="forbid")
quantize_experts: bool = Field(default=False, description="Quantize inactive experts for VRAM savings.")
experts_to_train: str = Field(default="all", description="`all` or comma-separated expert indices to train.")
class MultimodalConfig(BaseModel):
"""VLM multimodal fine-tuning configuration."""
model_config = ConfigDict(extra="forbid")
enabled: bool = Field(default=False, description="Enable VLM multimodal fine-tuning path.")
image_column: str = Field(default="image", description="Dataset column name for image paths or URLs.")
text_column: str = Field(default="text", description="Dataset column name for text or captions.")
class MergeInput(BaseModel):
"""One `{path, weight}` entry in ``merge.models``.
Typed rather than a free-form ``Dict[str, Any]`` for two reasons the old
shape got wrong in opposite directions.
A **silently ignored key**: ``merge.models`` accepted anything, so a
mergekit-style ``density:`` — a plausible thing for an operator to carry
over — was read, discarded and never mentioned. ``extra="forbid"`` matches
every other model in this module and turns that into an exit-1 config
error naming the key.
A **weight that poisons the merge**: ``weight`` was unvalidated, so
``.nan`` reached the algorithms. In TIES/DARE it propagates into every
merged tensor; SLERP is worse than propagation — ``t = w2/(w1+w2) if
(w1+w2) > 0 else 0.5`` makes a non-finite sum fall to the else branch, so
the operator's weights are **discarded** and the merge silently
interpolates at the midpoint. ``gt=0.0`` additionally rejects zero and
negative weights: a zero-sum was already a runtime error, and negatives
were documented as dangerous but legal, which left the renormalization
guard in ``_ties_merge_tensor`` reachable with a negative
``agree_weight_sum``. A value the code warns you not to use is better
refused at load time, where the exit code says "your config" (1) instead
of "your training run" (2).
"""
model_config = ConfigDict(extra="forbid")
path: str = Field(..., description="Filesystem path or Hub id of the source model / adapter to merge.")
weight: float = Field(
default=1.0,
gt=0.0,
allow_inf_nan=False,
description=(
"Relative contribution of this source, normalised across all entries. Must be finite and strictly positive."
),
)
class MergeConfig(BaseModel):
"""Post-training model merging configuration."""
model_config = ConfigDict(extra="forbid")
enabled: bool = Field(
default=False,
description="Enable post-training model merging (native TIES / DARE / SLERP / linear on state dicts).",
)
method: Literal["ties", "dare", "slerp", "linear"] = Field(
default="ties",
description="Merge algorithm: `ties` (TIES-merging), `dare` (DARE), `slerp` (spherical interpolation), `linear` (weighted average).",
)
models: List[MergeInput] = Field(
default=[],
description="List of `{path, weight}` entries naming the source models to merge.",
)
output_dir: str = Field(default="./merged_model", description="Directory to write the merged model into.")
ties_trim_fraction: float = Field(
default=0.2,
ge=0.0,
lt=1.0,
allow_inf_nan=False,
description=(
"TIES merge: fraction of smallest-magnitude deltas trimmed per task "
"(default `0.2` keeps the top ~80%; the published TIES default is sparser). "
"Must be in `[0.0, 1.0)`: `0.0` trims nothing, and `1.0` is refused because "
"trimming everything would make the merge a no-op — the implementation's "
"k-th-smallest threshold turns `1.0` into *keep only the maxima*, the opposite "
"of what the value reads as. Only consulted when `method` is `ties`."
),
)
dare_drop_rate: float = Field(
default=0.3,
ge=0.0,
le=1.0,
allow_inf_nan=False,
description=(
"DARE merge: probability each delta is randomly dropped before rescaling "
"(default `0.3`; the DARE paper recommends 0.9+ for fine-tuned deltas). "
"Only consulted when `method` is `dare`."
),
)
dare_seed: int = Field(
default=42,
description="DARE merge: RNG seed for the random drop mask, so a merge is reproducible run-to-run.",
)
@model_validator(mode="after")
def _validate_merge_inputs(self):
"""Reject an enabled merge with an unusable source list at config time.
Every merge algorithm needs at least two source models, each naming a
``path``. Without this check the failure surfaces only when the
no-train merge mode runs (`forgelm --merge`), where it lands on
EXIT_TRAINING_ERROR instead of the EXIT_CONFIG_ERROR a config defect
warrants — and ``--dry-run`` never catches it at all.
"""
if not self.enabled:
return self
if len(self.models) < 2:
raise ValueError(
"merge.enabled is true but fewer than two source models are listed; "
"every merge algorithm needs at least two `{path, weight}` entries in merge.models."
)
# The former ``"path" not in entry`` loop is gone: ``MergeInput.path``
# is a required field, so a missing path is now a Pydantic error naming
# the offending index instead of a hand-rolled message. Leaving the
# loop would have been dead code that still looked load-bearing.
return self
class ModelConfig(BaseModel):
model_config = ConfigDict(extra="forbid")
name_or_path: str = Field(description="HuggingFace Hub repo ID or local path to the base model.")
max_length: int = Field(
default=2048,
gt=0,
description="Tokenizer/context max sequence length used during training.",
json_schema_extra={"wizard": True},
)
load_in_4bit: bool = Field(default=True, description="Load the model in 4-bit NF4 quantisation (QLoRA path).")
backend: Literal["transformers", "unsloth"] = Field(
default="transformers",
description="Model backend: `transformers` (HF stock) or `unsloth` (Linux + CUDA only, faster).",
)
trust_remote_code: bool = Field(
default=False,
description="Allow execution of model-bundled code. Security: disabled by default for enterprise safety; set true only for models that explicitly require it.",
)
offline: bool = Field(
default=False,
description="Offline mode: set the HF offline variables (Hub, transformers, datasets). Models/datasets/extras must be available locally. Webhooks, judge/synthetic API clients and trackers are not stopped.",
)
revision: Optional[str] = Field(
default=None,
description=(
"Pin the base model + tokenizer to an HF Hub commit SHA (40-hex, recommended) or branch/tag. "
"Unset = the repo's default branch at load time; the SHA actually loaded is recorded in the "
"Annex IV manifest either way. Ignored, with a warning, when `name_or_path` is a local directory."
),
)
moe: Optional[MoeConfig] = Field(
default=None, description="MoE-specific settings (only consulted on MoE checkpoints)."
)
multimodal: Optional[MultimodalConfig] = Field(
default=None, description="VLM fine-tuning settings (only consulted for image-text models)."
)
bnb_4bit_use_double_quant: bool = Field(
default=True,
description="bitsandbytes: enable double-quantisation for the 4-bit codebook (small VRAM win).",
)
bnb_4bit_quant_type: Literal["nf4", "fp4"] = Field(
default="nf4",
description="bitsandbytes 4-bit quantisation scheme: `nf4` (recommended) or `fp4`.",
)
bnb_4bit_compute_dtype: Literal["auto", "bfloat16", "bf16", "float16", "fp16", "float32", "fp32"] = Field(
default="auto",
description="bitsandbytes 4-bit compute dtype: `auto` | `bfloat16` | `float16` | `float32` (each accepts the short `bf16`/`fp16`/`fp32` alias). `float32` negates most VRAM savings.",
)
@field_validator("revision")
@classmethod
def _validate_revision(cls, v: Optional[str]) -> Optional[str]:
return _validate_revision_literal(v, "model.revision")
@model_validator(mode="after")
def _warn_revision_on_local_path(self):
"""Warn when a pin is set against a local directory, which cannot honour it.
A local directory has no Hub commit to resolve against, so the pin is
inert and `model_integrity.json` is the identity that applies instead.
This warns rather than raises because ``os.path.isdir`` is true only
where the directory already exists: raising would make one YAML file
pass validation on a CI runner that has no such directory and fail on
the training host that does. A validation gate whose verdict depends
on the filesystem it runs on is worse than a loud warning. Load time
is where the loader holds the real path and can fail closed without
that asymmetry.
"""
if self.revision is not None and os.path.isdir(self.name_or_path):
logger.warning(
"model.revision=%r is set but model.name_or_path=%r is a local directory. "
"A local path carries no HF Hub commit, so the pin cannot be honoured and the "
"loaded bytes are whatever is on disk. Remove model.revision, or point "
"name_or_path at a Hub repo ID.",
self.revision,
self.name_or_path,
)
return self
@model_validator(mode="after")
def _warn_float32_qlora(self):
if (
self.load_in_4bit
and isinstance(self.bnb_4bit_compute_dtype, str)
and self.bnb_4bit_compute_dtype.lower() in ("fp32", "float32")
):
logger.warning(
"bnb_4bit_compute_dtype='float32' with load_in_4bit=True negates most VRAM savings. "
"Consider 'bfloat16' or 'auto'."
)
return self
class LoraConfigModel(BaseModel):
model_config = ConfigDict(extra="forbid")
r: int = Field(
default=8,
ge=1,
description="LoRA rank: dimension of the low-rank update matrices.",
json_schema_extra={"wizard": True},
)
alpha: int = Field(
default=16,
ge=1,
description="LoRA scaling factor (typically `2 * r`).",
json_schema_extra={"wizard": True},
)
dropout: FiniteFloat = Field(
default=0.1,
ge=0.0,
le=1.0,
description="Dropout rate applied to the LoRA update.",
json_schema_extra={"wizard": True},
)
bias: Literal["none", "all", "lora_only"] = Field(
default="none",
description="Which bias parameters to train: `none` (no biases), `all`, or `lora_only` (LoRA-injected layers only).",
)
method: Literal["lora", "dora", "pissa", "rslora"] = Field(
default="lora",
description="PEFT method: `lora` (standard), `dora` (weight-decomposed), `pissa` (singular value initialised), `rslora` (rank-stabilised).",
)
use_dora: bool = Field(
default=False,
description='Deprecated boolean shortcut for `method="dora"`; kept for backward compatibility.',
)
use_rslora: bool = Field(
default=False,
description='Deprecated boolean shortcut for `method="rslora"`; rank-stabilised LoRA for high ranks (r>64).',
)
target_modules: List[str] = Field(
default=["q_proj", "v_proj"],
description="Module-name fragments LoRA is injected into (typically attention projections).",
)
task_type: str = Field(
default="CAUSAL_LM", description="PEFT task type label (passed through to the PEFT library)."
)
@model_validator(mode="after")
def _normalize_peft_method(self):
# Reject contradictory deprecated flags rather than silently picking a
# winner (F-P1-FAB-20). Previously use_dora=True + use_rslora=True kept
# both booleans (model.py re-ORs them into the PEFT config), and a
# deprecated flag set against a non-matching explicit `method` was a
# silent no-op. Both are config-time mistakes → fail fast with exit 1.
if self.use_dora and self.use_rslora:
raise ValueError(
"lora.use_dora and lora.use_rslora are mutually exclusive "
"(DoRA and rsLoRA select different PEFT methods). Set a single "
"`method:` ('dora' or 'rslora') instead; both deprecated flags "
f"will be removed in {DEPRECATION_REMOVAL_VERSION}."
)
if self.use_dora and self.method not in ("lora", "dora"):
raise ValueError(
f"lora.use_dora=True contradicts method='{self.method}'. "
"Drop the deprecated flag and keep the explicit `method:`; "
f"use_dora will be removed in {DEPRECATION_REMOVAL_VERSION}."
)
if self.use_rslora and self.method not in ("lora", "rslora"):
raise ValueError(
f"lora.use_rslora=True contradicts method='{self.method}'. "
"Drop the deprecated flag and keep the explicit `method:`; "
f"use_rslora will be removed in {DEPRECATION_REMOVAL_VERSION}."
)
# Emit the deprecation unconditionally whenever the deprecated flag is set —
# including the compatible-redundant cases (use_dora + method='dora' or
# use_rslora + method='rslora'). Previously the warning only fired when
# method was 'lora', so operators who already wrote the correct explicit
# method alongside the deprecated flag received no nudge to drop it before
# the removal release (F-L-09).
if self.use_dora:
message = (
f"lora.use_dora=True is deprecated and will be removed in "
f"{DEPRECATION_REMOVAL_VERSION}. Use method='dora' instead."
) + (" Automatically setting method='dora'." if self.method == "lora" else "")
logger.warning(message)
warnings.warn(message, DeprecationWarning, stacklevel=2)
if self.method == "lora":
object.__setattr__(self, "method", "dora")
if self.use_rslora:
message = (
f"lora.use_rslora=True is deprecated and will be removed in "
f"{DEPRECATION_REMOVAL_VERSION}. Use method='rslora' instead."
) + (" Automatically setting method='rslora'." if self.method == "lora" else "")
logger.warning(message)
warnings.warn(message, DeprecationWarning, stacklevel=2)
if self.method == "lora":
object.__setattr__(self, "method", "rslora")
return self
class TrainingConfig(BaseModel):
# populate_by_name lets users keep the legacy `grpo_max_new_tokens` field
# name in their YAML even though the canonical attribute is now
# `grpo_max_completion_length` (matches TRL's GRPOConfig field). Without
# this flag, Pydantic would only accept the alias on input, never the
# canonical name.
model_config = ConfigDict(extra="forbid", populate_by_name=True)
output_dir: str = Field(
default="./checkpoints", description="Directory for intermediate checkpoints + audit log + compliance bundle."
)
final_model_dir: str = Field(
default="final_model", validate_default=True, description="Isolated subdirectory of output_dir."
)
_validate_final_model_dir = field_validator("final_model_dir")(_validate_config_directory)
merge_adapters: bool = Field(
default=False,
description="When SFT finishes, merge LoRA adapters into the base model (writes a full-weight model).",
)
trainer_type: Literal["sft", "orpo", "dpo", "simpo", "kto", "grpo"] = Field(
default="sft",
description="Alignment paradigm: `sft` (supervised), `orpo`, `dpo`, `simpo`, `kto`, or `grpo`.",
)
max_steps: int = Field(
default=-1, ge=-1, description="Hard step cap; `-1` = use `num_train_epochs`, positive value overrides epochs."
)
num_train_epochs: int = Field(
default=3,
ge=1,
description="Number of training epochs (only consulted when `max_steps == -1`).",
json_schema_extra={"wizard": True},
)
per_device_train_batch_size: int = Field(
default=4,
ge=1,
description="Micro-batch size per GPU. Multiply by `gradient_accumulation_steps` × world size for effective batch.",
json_schema_extra={"wizard": True},
)
gradient_accumulation_steps: int = Field(
default=2,
ge=1,
description="Number of micro-batches to accumulate before each optimiser step.",
json_schema_extra={"wizard": True},
)
learning_rate: FiniteFloat = Field(
default=2e-5,
gt=0,
description="Peak learning rate. LoRA / QLoRA usually tolerates 2e-4; full-finetune wants 2e-5.",
json_schema_extra={"wizard": True},
)
warmup_ratio: FiniteFloat = Field(
default=0.1, ge=0, le=1, description="Fraction of total steps spent warming up the learning rate from 0 → peak."
)
weight_decay: FiniteFloat = Field(
default=0.01, ge=0, description="L2 weight-decay coefficient applied by the optimiser."
)
eval_steps: int = Field(default=200, ge=1, description="Run validation every N optimiser steps.")
save_steps: int = Field(default=200, ge=1, description="Write a checkpoint every N optimiser steps.")
save_total_limit: int = Field(default=3, ge=1, description="Retain at most N checkpoints (oldest evicted first).")
packing: bool = Field(
default=False, description="Pack short sequences into one to maximise GPU compute utilisation."
)
early_stopping_patience: int = Field(
default=3, ge=1, description="Stop training after N evals without validation-loss improvement."
)
orpo_beta: FiniteFloat = Field(
default=0.1, gt=0, description="ORPO odds-ratio weight (alignment paradigm parameter)."
)
dpo_beta: FiniteFloat = Field(default=0.1, gt=0, description="DPO temperature parameter.")
simpo_gamma: FiniteFloat = Field(default=0.5, ge=0, description="SimPO margin term.")
simpo_beta: FiniteFloat = Field(default=2.0, gt=0, description="SimPO scaling parameter.")
kto_beta: FiniteFloat = Field(default=0.1, gt=0, description="KTO loss parameter.")
grpo_num_generations: int = Field(
default=4, ge=2, description="GRPO: number of responses to generate per prompt during rollout."
)
# TRL >=0.12 renamed `max_new_tokens` to `max_completion_length` on GRPOConfig.
# We mirror the TRL spelling, but accept the legacy name via Pydantic alias
# so existing YAML configs and templates keep working without edits.
grpo_max_completion_length: int = Field(
default=512,
ge=1,
alias="grpo_max_new_tokens",
description="GRPO: max tokens per generated completion (TRL field name).",
)
grpo_reward_model: Optional[str] = Field(
default=None,
description=(
"GRPO: HF model path for reward scoring. When None, the trainer wires "
"`combined_format_length_reward` as a baseline (always-on, gradient-rich "
"format + length shaping signal). If the dataset additionally carries a "
"`gold_answer` field (see the grpo-math template), a regex correctness "
"reward is appended for additive scoring — TRL sums multiple reward funcs."
),
)
grpo_reward_model_revision: Optional[str] = Field(
default=None,
description=(
"Pin the GRPO reward model to an HF Hub commit SHA or ref. The reward model is in the "
"loss, so an unpinned one makes the training signal irreproducible."
),
)
galore_enabled: bool = Field(
default=False, description="GaLore: enable optimizer-level memory optimisation (alternative to LoRA)."
)
galore_optim: Literal[
"galore_adamw",
"galore_adamw_8bit",
"galore_adafactor",
"galore_adamw_layerwise",
"galore_adamw_8bit_layerwise",
"galore_adafactor_layerwise",
] = Field(
default="galore_adamw",
description="GaLore optimiser variant. `_8bit` halves optimiser-state VRAM; `_layerwise` cuts peak by recomputing per-layer.",
)
galore_rank: int = Field(
default=128, ge=1, description="GaLore: low-rank subspace dimension for gradient projection."
)
galore_update_proj_gap: int = Field(
default=200,
ge=1,
description="GaLore: number of steps between SVD re-computations of the projection.",
)
galore_scale: FiniteFloat = Field(
default=0.25, gt=0, description="GaLore: gradient scaling factor (analogous to LoRA alpha)."
)
galore_proj_type: Literal["std", "reverse_std", "right", "left", "full"] = Field(
default="std",
description="GaLore projection type. `std` is the documented default; `full` disables projection (debug only).",
)
galore_target_modules: Optional[List[str]] = Field(
default=None,
description='GaLore target-module regexes. `None` falls back to `[r".*.attn.*", r".*.mlp.*"]`.',
)
rope_scaling: Optional[Dict[str, Any]] = Field(
default=None,
description=(
"RoPE scaling for long-context fine-tuning. `linear` / `dynamic` / `yarn` take a "
'scalar `factor` (e.g. `{"type": "linear", "factor": 4.0}`); `longrope` (e.g. Phi-3) '
"takes per-dimension `short_factor` / `long_factor` lists instead, with `factor` "
'optional (e.g. `{"type": "longrope", "short_factor": [...], "long_factor": [...]}`). '
"The transformers-5 canonical `rope_type` key is accepted as an alias for `type`."
),
)
neftune_noise_alpha: Optional[FiniteFloat] = Field(
default=None,
description="NEFTune: add Gaussian noise to embeddings during training (5.0 is a common value; improves SFT quality).",
)
sliding_window_attention: Optional[int] = Field(
default=None,
description="Override the model's sliding-window-attention size (e.g. 4096 for Mistral). None = use the model default.",
)
sample_packing: bool = Field(
default=False,
description=(
"Deprecated alias for `packing`; TRL exposes a single sequence-packing knob. "
"Setting `sample_packing: true` forwards to `packing: true` with a "
f"`DeprecationWarning`. Will be removed in {DEPRECATION_REMOVAL_VERSION} — use `packing` instead."
),
)
oom_recovery: bool = Field(
default=False, description="Auto-halve `per_device_train_batch_size` on CUDA OOM and retry."
)
oom_recovery_min_batch_size: int = Field(
default=1, ge=1, description="Stop OOM retry once batch size reaches this floor; raise instead."
)
report_to: Literal["tensorboard", "wandb", "mlflow", "none"] = Field(
default="tensorboard",
description="Experiment-tracking backend. `wandb` requires the `[tracking]` extra, `mlflow` the `[tracking-mlflow]` extra.",
)
run_name: Optional[str] = Field(default=None, description="W&B / MLflow run name. Auto-generated when None.")
gpu_cost_per_hour: Optional[FiniteFloat] = Field(
default=None,
ge=0,
description="USD per hour for the training GPU. None = auto-detect from known GPUs (used by the cost-estimation report).",
)
@field_validator("grpo_reward_model_revision")
@classmethod
def _validate_grpo_reward_model_revision(cls, v: Optional[str]) -> Optional[str]:
return _validate_revision_literal(v, "training.grpo_reward_model_revision")
@model_validator(mode="after")
def _validate_grpo_reward_revision_target(self):
"""Reject a reward-model pin that names no reward model.
With ``grpo_reward_model`` unset the trainer wires the built-in
``combined_format_length_reward`` fallback and never loads a repo, so
the pin is discarded in silence — the same dead-config fail-open the
judge and teacher pins are validated against.
"""
if self.grpo_reward_model_revision is not None and not self.grpo_reward_model:
raise ValueError(
"training.grpo_reward_model_revision is set but training.grpo_reward_model is not, "
"so the pin names no repository: the trainer falls back to the built-in "
"format/length shaping reward and the pin is silently discarded. "
"Set training.grpo_reward_model, or remove the pin."
)
return self
@field_validator("rope_scaling")
@classmethod
def _validate_rope_scaling(cls, v):
"""Reject a malformed ``rope_scaling`` payload at config time.
``rope_scaling`` flows verbatim into ``AutoModelForCausalLM.from_pretrained``
via ``model.py``; a typo'd type, a missing scaling parameter, or a
non-numeric factor would otherwise pass ``--dry-run`` cleanly and only
crash once training loads the model (EXIT_TRAINING_ERROR) instead of at
config load (EXIT_CONFIG_ERROR) — the same gap the sibling validators
(``_validate_merge_inputs``, ``_validate_synthetic_payload``) close for
their blocks.
The required parameters are per-type, mirroring transformers'
``RotaryEmbeddingConfigMixin.validate_rope``: ``linear`` / ``dynamic`` /
``yarn`` take a scalar ``factor``; ``longrope`` (e.g. Phi-3 long-context)
is parameterised by per-dimension ``short_factor`` / ``long_factor`` lists
and treats ``factor`` as optional. transformers 5.x canonicalises the
discriminator as ``rope_type`` while keeping ``type`` as a backward-compat
alias, so either key is accepted here.
"""
if v is None:
return v
def _check_factor(value):
# ``bool`` is a subclass of ``int`` — exclude it explicitly so
# ``factor: true`` is rejected as a type error rather than treated as 1.
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ValueError(f"training.rope_scaling.factor must be a number, got {type(value).__name__}.")
# ``nan <= 0`` and ``inf <= 0`` are both False, so check finiteness first.
if not math.isfinite(value) or value <= 0:
raise ValueError(f"training.rope_scaling.factor must be positive and finite, got {value}.")
def _check_factor_list(key):
value = v.get(key)
if not isinstance(value, list) or not value:
raise ValueError(
f"training.rope_scaling.{key} must be a non-empty list of numbers when "
"rope_type='longrope' (per-dimension scaling factors)."
)
for item in value:
if isinstance(item, bool) or not isinstance(item, (int, float)):
raise ValueError(
f"training.rope_scaling.{key} must contain only numbers, got {type(item).__name__}."
)
if not math.isfinite(item):
raise ValueError(f"training.rope_scaling.{key} must contain only finite numbers, got {item}.")
allowed_types = ("linear", "dynamic", "yarn", "longrope")
# transformers 5.x reads ``rope_parameters.get("rope_type", ...get("type"))``,
# so accept the canonical ``rope_type`` key as well as the legacy ``type`` alias.
rope_type = v.get("rope_type", v.get("type"))
if rope_type is None:
raise ValueError(
"training.rope_scaling requires a `type` key "
f"(or its transformers-5 alias `rope_type`), one of {list(allowed_types)}, "
"e.g. {'type': 'linear', 'factor': 4.0}."
)
if rope_type not in allowed_types:
raise ValueError(
f"training.rope_scaling.type={rope_type!r} is not recognized; must be one of {list(allowed_types)}."
)
if rope_type == "longrope":
# transformers' longrope path is parameterised by short_factor /
# long_factor lists; a top-level scalar factor is optional.
_check_factor_list("short_factor")
_check_factor_list("long_factor")
if v.get("factor") is not None:
_check_factor(v["factor"])
else:
factor = v.get("factor")
if factor is None:
raise ValueError(
"training.rope_scaling requires a positive `factor` (context-length multiplier, e.g. 4.0)."
)
_check_factor(factor)
return v
@model_validator(mode="after")
def _forward_deprecated_sample_packing(self):
"""Forward the deprecated ``sample_packing`` flag onto ``packing``.
``sample_packing`` was historically documented as a functional
sequence-packing knob but was never consumed by the trainer (TRL's
``SFTConfig`` exposes a single ``packing`` parameter), so an operator
who set it got a silent no-op. We now alias it to ``packing`` so the
documented behaviour actually fires during the deprecation window, and
emit both a ``DeprecationWarning`` (for ``-W error`` / CI deprecation
sweeps) and a ``logger.warning`` (visible on the CLI path), mirroring
the ``lora.use_dora`` alias pattern. Removal target:
:data:`DEPRECATION_REMOVAL_VERSION`.
"""
if self.sample_packing:
# Always notify: emit when the deprecated flag is set regardless of
# whether packing is also true. Previously the guard was
# ``if self.sample_packing and not self.packing``, which silently
# swallowed the deprecation when an operator wrote both
# ``sample_packing: true`` and ``packing: true``, leaving them with
# no nudge to remove the deprecated key before removal (F-M-14).
logger.warning(
"training.sample_packing is deprecated and forwards to training.packing. "
f"Use `packing: true` instead; sample_packing will be removed in {DEPRECATION_REMOVAL_VERSION}."
)
warnings.warn(
"`training.sample_packing` is deprecated and forwards to "
"`training.packing`. Use `packing: true` instead; the deprecated "
f"field will be removed in {DEPRECATION_REMOVAL_VERSION}.",
DeprecationWarning,
stacklevel=2,
)
if not self.packing:
object.__setattr__(self, "packing", True)
return self
class DistributedConfig(BaseModel):
"""Configuration for multi-GPU distributed training via DeepSpeed or FSDP."""
model_config = ConfigDict(extra="forbid")
strategy: Optional[Literal["deepspeed", "fsdp"]] = Field(
default=None,
description="Distributed strategy: `deepspeed`, `fsdp`, or `None` for single-GPU (no distributed wrapping).",
)
deepspeed_config: Optional[str] = Field(
default=None,
description="DeepSpeed config: filesystem path to a DS JSON. The preset names (`zero2`, `zero3`, `zero3_offload`) resolve only from a source checkout, not a pip or Docker install.",
)
fsdp_strategy: Literal["full_shard", "shard_grad_op"] = Field(
default="full_shard",
description="FSDP sharding strategy. `full_shard` is the production default; `shard_grad_op` shards gradients and optimizer states only. `no_shard` and `hybrid_shard` are not accepted: transformers 5.10+ runs FSDP2, which supports neither.",
)
fsdp_auto_wrap: bool = Field(default=True, description="FSDP auto-wrap; ignored by FSDP2 (transformers 5.10+).")
fsdp_offload: bool = Field(
default=False, description="FSDP: offload parameters to CPU between forward and backward (slower, less VRAM)."
)
fsdp_backward_prefetch: Literal["backward_pre", "backward_post"] = Field(
default="backward_pre",
description="FSDP backward-prefetch policy. Not honoured by transformers 5.10+ (FSDP2 prefetches on its own); `backward_post` logs a warning.",
)
fsdp_state_dict_type: Literal["FULL_STATE_DICT", "SHARDED_STATE_DICT"] = Field(
default="FULL_STATE_DICT",
description="FSDP checkpoint format. `FULL_STATE_DICT` consolidates to rank 0 (HF-compatible); `SHARDED_STATE_DICT` keeps shards separate.",
)
class DataGovernanceConfig(BaseModel):
"""Art. 10: Data governance metadata."""
model_config = ConfigDict(extra="forbid")
collection_method: str = Field(
default="", description="Article 10(2)(b): how the training data was collected (free-text)."
)
annotation_process: str = Field(
default="", description="Article 10(2)(b): annotation / labelling methodology (free-text)."
)
known_biases: str = Field(
default="", description="Article 10(2)(f): documented data biases the operator is aware of."
)
personal_data_included: bool = Field(
default=False,
description="Article 10(5): whether the training data contains personal data of identifiable subjects.",
)
dpia_completed: bool = Field(
default=False, description="Article 35 GDPR: Data Protection Impact Assessment completed for this dataset."
)
class DataConfig(BaseModel):
model_config = ConfigDict(extra="forbid")
dataset_name_or_path: str = Field(
description="Primary dataset: HuggingFace Hub ID, local JSONL path, or directory of JSONL files."
)
extra_datasets: Optional[List[str]] = Field(
default=None, description="Additional datasets to mix in alongside the primary."
)
mix_ratio: Optional[List[float]] = Field(
default=None,
description="Per-dataset weight (primary + extras). Uniform when None; values must be non-negative and not all zero.",
)
shuffle: bool = Field(default=True, description="Shuffle the merged corpus before splitting train/validation.")
clean_text: bool = Field(
default=True, description="Strip excessive whitespace + control characters before tokenisation."
)
add_eos: bool = Field(
default=True, description="Append the EOS token to every example so generation knows where to stop."
)
governance: Optional[DataGovernanceConfig] = Field(
default=None, description="EU AI Act Article 10 data governance metadata."
)
@field_validator("mix_ratio")
@classmethod
def _validate_mix_ratio(cls, v):
if v is not None:
# Reject NaN / inf before the comparison checks: `nan < 0` and
# `nan == 0` are both False, so a non-finite weight would otherwise
# slip through and crash later in `data._apply_mix_ratio` at
# `int(max_dataset_size * nan)` (a runtime exit-2 instead of a
# config-time exit-1).
if any(not math.isfinite(r) for r in v):
raise ValueError("mix_ratio values must be finite (no NaN or inf).")
if any(r < 0 for r in v):
raise ValueError("mix_ratio values must be non-negative.")
if all(r == 0 for r in v):
raise ValueError("mix_ratio values cannot all be zero.")
return v
@model_validator(mode="after")
def _validate_mix_ratio_length(self):
"""Require one mix_ratio weight per dataset (primary + extras).
The field-level validator above cannot see ``extra_datasets``, so the
cross-field length check lives here. A mismatch used to validate
cleanly and silently fall back to uniform mixing at runtime
(``data._merge_extra_datasets``) — i.e. the operator's declared
mixture was replaced by a different one with no error.
"""
if self.mix_ratio is not None:
expected = 1 + len(self.extra_datasets or [])
if len(self.mix_ratio) != expected:
raise ValueError(
f"mix_ratio length ({len(self.mix_ratio)}) must equal the dataset count "
f"({expected} = 1 primary + {len(self.extra_datasets or [])} extra_datasets). "
"List one weight per dataset, primary first."
)
return self
class BenchmarkConfig(BaseModel):
"""Configuration for post-training benchmark evaluation via lm-evaluation-harness."""
model_config = ConfigDict(extra="forbid")
enabled: bool = Field(default=False, description="Enable lm-evaluation-harness benchmark scoring after training.")
tasks: List[str] = Field(default=[], description='lm-eval task names (e.g. `["arc_easy", "hellaswag", "mmlu"]`).')
num_fewshot: Optional[int] = Field(
default=None, ge=0, description="Few-shot example count. None = use the task's documented default."
)
batch_size: str = Field(default="auto", description='lm-eval batch size: `"auto"` or an integer string.')
limit: Optional[int] = Field(
default=None, ge=1, description="Cap samples per task for quick checks. None = full task."
)
output_dir: Optional[str] = Field(
default=None, description="Where to save benchmark results JSON. Defaults to the training output_dir."
)
min_score: Optional[FiniteFloat] = Field(
default=None,
ge=0.0,
le=1.0,
description="Minimum average accuracy (0.0–1.0). When set + auto_revert=True, falling below deletes the saved model directory (nothing is restored).",
)
@model_validator(mode="after")
def _enabled_requires_tasks(self):
"""Reject an enabled benchmark gate with no tasks (F-P1-FAB-19).
``enabled=True`` + ``tasks=[]`` previously validated cleanly, then
``trainer.py`` short-circuited to a skip-as-pass at runtime — the gate
the operator explicitly enabled (possibly with ``min_score`` +
``auto_revert``) never executed and emitted no benchmark audit event.
That is a silently-disabled decision gate; fail fast at config time
(exit 1) so the operator lists a task or disables the block.
"""
if self.enabled and not self.tasks:
raise ValueError(
"evaluation.benchmark.enabled is true but tasks is empty; "
"list at least one lm-eval task (e.g. ['arc_easy']) or set "
"enabled: false."
)
return self
class SafetyConfig(BaseModel):
"""Post-training safety evaluation configuration."""
model_config = ConfigDict(extra="forbid")
enabled: bool = Field(default=False, description="Enable post-training safety evaluation.")
classifier: str = Field(
default="meta-llama/Llama-Guard-3-8B", description="Harm classifier model (HF Hub ID or local path)."
)
classifier_mode: Literal["auto", "classification", "generation"] = Field(
default="auto",
description=(
"How the classifier is scored: `auto` picks generation-based scoring for a known "
"generative Llama-Guard checkpoint (e.g. the default `meta-llama/Llama-Guard-3-8B`) "
"and the `text-classification` pipeline otherwise; `classification` forces the "
"pipeline path (needs a trained safe/unsafe head); `generation` forces generation-based "
"Llama-Guard scoring (parse the generated `safe`/`unsafe`+S-code verdict)."
),
)
classifier_revision: Optional[str] = Field(
default=None,
description=(
"Pin the harm classifier to an HF Hub commit SHA or ref. An upstream classifier "
"re-tune silently moves the auto-revert pass/fail line with no config diff. "
"Applies to the training-time safety gate only, and the resolved commit is "
"recorded under `model_lineage.component_revisions`; standalone `forgelm "
"safety-eval` takes no config and loads its classifier unpinned."
),
)
test_prompts: str = Field(
default="safety_prompts.jsonl", description="Path to JSONL file with adversarial test prompts."
)
max_safety_regression: FiniteFloat = Field(
default=0.05,
ge=0.0,
le=1.0,
description="Maximum allowed unsafe-response ratio (0.0–1.0). Auto-revert triggers when exceeded.",
)
scoring: Literal["binary", "confidence_weighted"] = Field(
default="binary",
description=(
"Scoring scheme: `binary` (safe/unsafe per response) or `confidence_weighted` "
"(Llama Guard probability). Caveat: `confidence_weighted` yields a true probability "
"only on the `classification` pipeline path; under `generation` mode (what the default "
"generative Llama-Guard checkpoint resolves to) each verdict is categorical (safe=1.0 / "
"unsafe=0.0), so the weighted score degenerates to the binary safe-ratio."
),
)
min_safety_score: Optional[FiniteFloat] = Field(
default=None,
ge=0.0,
le=1.0,
description='Weighted score threshold (0.0–1.0); used when `scoring="confidence_weighted"`.',
)
min_classifier_confidence: FiniteFloat = Field(
default=0.7,
ge=0.0,
le=1.0,
description="Flag responses with classifier confidence below this floor for human review.",
)
track_categories: bool = Field(
default=False, description="Parse Llama Guard S1-S14 harm categories per-response and surface in the report."
)
# ``Dict[str, float]`` alone carries Pydantic's default
# ``allow_inf_nan=True`` on the *values*, so ``{"critical": .nan}`` — an
# ordinary YAML spelling — reached the gate unvalidated. Every one of these
# is compared with ``>``, and ``rate > nan`` is False, so a single
# non-finite entry silently disarmed the severity gate for that level.
# ``Annotated`` puts the constraint on the value type, which is the only
# place a per-entry rule can live.
severity_thresholds: Optional[Dict[str, Annotated[float, Field(ge=0.0, le=1.0, allow_inf_nan=False)]]] = Field(
default=None,
description=(
'Per-severity limits: e.g. `{"critical": 0, "high": 0.01}`. Auto-revert when exceeded. '
"Each value must be a finite rate in `[0.0, 1.0]`."
),
)
batch_size: int = Field(
default=8, ge=1, description="Batched generation size for safety evaluation. 1 disables batching."
)
include_eval_samples: bool = Field(