Skip to content

Commit 14d3b9f

Browse files
committed
feat: make parameter ranges configurable
1 parent 27097bf commit 14d3b9f

4 files changed

Lines changed: 205 additions & 51 deletions

File tree

config.default.toml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,3 +160,32 @@ column = "text"
160160
dataset = "mlabonne/harmful_behaviors"
161161
split = "test[:100]"
162162
column = "text"
163+
164+
# The parameter ranges used to choose settings for abliteration. With the
165+
# exception of direction_scope and max_weight, all values are between 0 and 1.
166+
# min_weight is set relative to max_weight, and the other values are relative
167+
# to the number of layers in the model. By default, the same ranges are used
168+
# across all modules. If the individual module names for a given model are
169+
# known, separate settings can be specified per module by dotted keys
170+
# like foo.bar.baz = 1 or nested objects like foo = { bar = { baz = 1 } }.
171+
[parameter_ranges]
172+
# The different refusal direction scopes that can be applied to each trial.
173+
direction_scope = [
174+
# Choose a refusal direction by interpolating between 2 layers and apply it globally.
175+
"global",
176+
# For each layer within range, apply the layer's own refusal direction to itself.
177+
"per layer",
178+
]
179+
# For the global direction scope, the layer from which to choose the refusal direction.
180+
direction_index = { low = 0.4, high = 0.9 }
181+
# The maximum weight with which to apply the abliteration. Set log = true to
182+
# sample from the log space, which will select lower values more frequently.
183+
# Note that low must be greater than 0 when log = true.
184+
max_weight = { low = 0.8, high = 1.5, log = false }
185+
# The position (layer) at which the maximum weight should be applied.
186+
max_weight_position = { low = 0.6, high = 1.0 }
187+
# The minimum weight as a fraction of the maximum weight.
188+
min_weight = { low = 0.0, high = 1.0 }
189+
# The distance from max_weight_position across which the weight drops from
190+
# max_weight to min_weight. Beyond this distance, the weight is set to 0.
191+
min_weight_distance = { low = 0.0, high = 0.6 }

src/heretic/config.py

Lines changed: 120 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,18 @@
22
# Copyright (C) 2025-2026 Philipp Emanuel Weidmann <pew@worldwidemann.com> + contributors
33

44
from enum import Enum
5-
from typing import Dict
5+
from typing import Dict, Type, TypeAlias, TypeVar
66

7-
from pydantic import BaseModel, Field
7+
from optuna import Trial
8+
from pydantic import BaseModel, Field, PrivateAttr, RootModel
89
from pydantic_settings import (
910
BaseSettings,
1011
CliSettingsSource,
1112
EnvSettingsSource,
1213
PydanticBaseSettingsSource,
1314
TomlConfigSettingsSource,
1415
)
16+
from typing_extensions import TypeAliasType
1517

1618

1719
class QuantizationMethod(str, Enum):
@@ -61,6 +63,63 @@ class DatasetSpecification(BaseModel):
6163
)
6264

6365

66+
class CategoricalParamSpecification(RootModel[list[str]]):
67+
_name: str | None = PrivateAttr(None)
68+
69+
def __len__(self) -> int:
70+
return len(self.root)
71+
72+
def __contains__(self, item: str) -> bool:
73+
return item in self.root
74+
75+
def set_name(self, name: str):
76+
self._name = name
77+
78+
def suggest_categorical(self, trial: Trial) -> str:
79+
if self._name is None:
80+
raise TypeError("Parameter name is not set.")
81+
if len(self.root) == 1:
82+
return self.root[0]
83+
return trial.suggest_categorical(self._name, self.root)
84+
85+
86+
class FloatParamSpecification(BaseModel):
87+
low: float = Field(
88+
description="Lower endpoint of the range of suggested values (inclusive).",
89+
)
90+
91+
high: float = Field(
92+
description="Upper endpoint of the range of suggested values (inclusive).",
93+
)
94+
95+
log: bool = Field(
96+
default=False,
97+
description="If true, the value is sampled from the range in the log domain.",
98+
)
99+
100+
_name: str | None = PrivateAttr(None)
101+
102+
def set_name(self, name: str):
103+
self._name = name
104+
105+
def suggest_float(self, trial: Trial) -> float:
106+
if self._name is None:
107+
raise TypeError("Parameter name is not set.")
108+
if self.low == self.high:
109+
return self.low
110+
return trial.suggest_float(self._name, self.low, self.high, log=self.log)
111+
112+
113+
ParamSpecification: TypeAlias = CategoricalParamSpecification | FloatParamSpecification
114+
ParamSpecificationRecursive = TypeAliasType(
115+
"ParamSpecificationRecursive",
116+
"ParamSpecification | dict[str, ParamSpecificationRecursive]",
117+
)
118+
ParamSpecificationType = TypeVar(
119+
"ParamSpecificationType", CategoricalParamSpecification, FloatParamSpecification
120+
)
121+
122+
64123
class Settings(BaseSettings):
65124
model: str = Field(description="Hugging Face model ID, or path to model on disk.")
66125

@@ -313,6 +372,13 @@ class Settings(BaseSettings):
313372
description="Dataset of prompts that tend to result in refusals (used for evaluating model performance).",
314373
)
315374

375+
parameter_ranges: dict[str, ParamSpecificationRecursive] = Field(
376+
default={},
377+
description="Parameter ranges, per parameter or per module and parameter.",
378+
)
379+
380+
_parameter_range_defaults: dict[str, ParamSpecification] | None = PrivateAttr(None)
381+
316382
@classmethod
317383
def settings_customise_sources(
318384
cls,
@@ -335,3 +401,55 @@ def settings_customise_sources(
335401
file_secret_settings,
336402
TomlConfigSettingsSource(settings_cls, toml_file="config.toml"),
337403
)
404+
405+
def set_parameter_range_defaults(self, defaults: dict[str, ParamSpecification]):
406+
self._parameter_range_defaults = defaults
407+
408+
def _param_spec(
409+
self, name: str, spec_type: Type[ParamSpecificationType]
410+
) -> ParamSpecificationType:
411+
"""
412+
Finds the setting with the longest matching suffix.
413+
For example, if we're looking for setting `foo.bar.baz` and
414+
both `baz` and `bar.baz` are available, return `bar.baz`.
415+
"""
416+
if self._parameter_range_defaults is None:
417+
raise ValueError(
418+
"Parameter defaults are not initialized. "
419+
"Call 'set_parameter_range_defaults()' first."
420+
)
421+
split_name = name.split(".")
422+
# Get the default parameter specification.
423+
most_specific_spec = self._parameter_range_defaults[split_name[-1]]
424+
if most_specific_spec is None:
425+
raise TypeError(f"No parameter default found for {split_name[-1]}.")
426+
if not isinstance(most_specific_spec, spec_type):
427+
raise TypeError(
428+
f"Expected parameter default to be an instance of {spec_type.__name__}, "
429+
f"but got {type(most_specific_spec).__name__} instead."
430+
)
431+
# Look for an override from the config.
432+
split_len = len(split_name)
433+
for i in range(split_len - 1, -1, -1):
434+
spec = self.parameter_ranges.get(split_name[i], None)
435+
for j in range(i + 1, split_len):
436+
if spec is None:
437+
break
438+
if not isinstance(spec, dict):
439+
# Found a setting for the prefix, but didn't match suffix.
440+
spec = None
441+
break
442+
spec = spec.get(split_name[j], None)
443+
if spec is not None and isinstance(spec, spec_type):
444+
most_specific_spec = spec
445+
# Set the specific name (for suggest_categorical() and suggest_float()).
446+
most_specific_spec.set_name(name)
447+
# most_specific_spec can only be ParamSpecificationType,
448+
# but ty's support for narrowing generics is still limited.
449+
return most_specific_spec # ty:ignore[invalid-return-type]
450+
451+
def categorical_spec(self, name: str) -> CategoricalParamSpecification:
452+
return self._param_spec(name, CategoricalParamSpecification)
453+
454+
def float_spec(self, name: str) -> FloatParamSpecification:
455+
return self._param_spec(name, FloatParamSpecification)

src/heretic/main.py

Lines changed: 54 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,12 @@
3636
from rich.traceback import install
3737

3838
from .analyzer import Analyzer
39-
from .config import QuantizationMethod, Settings
39+
from .config import (
40+
CategoricalParamSpecification,
41+
FloatParamSpecification,
42+
QuantizationMethod,
43+
Settings,
44+
)
4045
from .evaluator import Evaluator
4146
from .model import AbliterationParameters, Model, get_model_class
4247
from .utils import (
@@ -446,6 +451,25 @@ def run():
446451
del good_residuals, bad_residuals, analyzer
447452
empty_cache()
448453

454+
settings.set_parameter_range_defaults(
455+
{
456+
"direction_scope": CategoricalParamSpecification(["global", "per layer"]),
457+
# Discrimination between "harmful" and "harmless" inputs is usually strongest
458+
# in layers slightly past the midpoint of the layer stack. See the original
459+
# abliteration paper (https://arxiv.org/abs/2406.11717) for a deeper analysis.
460+
"direction_index": FloatParamSpecification(low=0.4, high=0.9),
461+
# The parameter ranges are based on experiments with various models
462+
# and much wider ranges. They are not set in stone and might have to be
463+
# adjusted for future models.
464+
"max_weight": FloatParamSpecification(low=0.8, high=1.5, log=False),
465+
"max_weight_position": FloatParamSpecification(low=0.6, high=1.0),
466+
# For sampling purposes, min_weight is expressed as a fraction of max_weight,
467+
# because multivariate TPE doesn't support variable-range parameters.
468+
"min_weight": FloatParamSpecification(low=0.0, high=1.0),
469+
"min_weight_distance": FloatParamSpecification(low=0.0, high=0.6),
470+
}
471+
)
472+
449473
trial_index = 0
450474
start_index = 0
451475
start_time = time.perf_counter()
@@ -455,67 +479,48 @@ def objective(trial: Trial) -> tuple[float, float]:
455479
trial_index += 1
456480
trial.set_user_attr("index", trial_index)
457481

458-
direction_scope = trial.suggest_categorical(
459-
"direction_scope",
460-
[
461-
"global",
462-
"per layer",
463-
],
464-
)
482+
direction_scope_choices = settings.categorical_spec("direction_scope")
483+
direction_scope = direction_scope_choices.suggest_categorical(trial)
465484

466485
last_layer_index = len(model.get_layers()) - 1
467486

468-
# Discrimination between "harmful" and "harmless" inputs is usually strongest
469-
# in layers slightly past the midpoint of the layer stack. See the original
470-
# abliteration paper (https://arxiv.org/abs/2406.11717) for a deeper analysis.
471-
#
472-
# Note that we always sample this parameter even though we only need it for
473-
# the "global" direction scope. The reason is that multivariate TPE doesn't
474-
# work with conditional or variable-range parameters.
475-
direction_index = trial.suggest_float(
476-
"direction_index",
477-
0.4 * last_layer_index,
478-
0.9 * last_layer_index,
479-
)
480-
481-
if direction_scope == "per layer":
487+
# Note that we always sample this parameter when the "global" direction
488+
# scope is included in the choices, even though we only need it for the
489+
# "global" direction scope itself. The reason is that multivariate TPE
490+
# doesn't work with conditional or variable-range parameters.
491+
if "global" in direction_scope_choices:
492+
direction_index_range = settings.float_spec("direction_index")
493+
direction_index = direction_index_range.suggest_float(trial)
494+
direction_index *= last_layer_index
495+
if direction_scope != "global":
482496
direction_index = None
483497

484498
parameters = {}
485499

486500
for component in model.get_abliterable_components():
487-
# The parameter ranges are based on experiments with various models
488-
# and much wider ranges. They are not set in stone and might have to be
489-
# adjusted for future models.
490-
max_weight = trial.suggest_float(
491-
f"{component}.max_weight",
492-
0.8,
493-
1.5,
494-
)
495-
max_weight_position = trial.suggest_float(
496-
f"{component}.max_weight_position",
497-
0.6 * last_layer_index,
498-
1.0 * last_layer_index,
499-
)
500-
# For sampling purposes, min_weight is expressed as a fraction of max_weight,
501-
# again because multivariate TPE doesn't support variable-range parameters.
502-
# The value is transformed into the actual min_weight value below.
503-
min_weight = trial.suggest_float(
504-
f"{component}.min_weight",
505-
0.0,
506-
1.0,
501+
max_weight_range = settings.float_spec(f"{component}.max_weight")
502+
if max_weight_range.high == 0.0:
503+
continue
504+
max_weight = max_weight_range.suggest_float(trial)
505+
506+
max_weight_position_range = settings.float_spec(
507+
f"{component}.max_weight_position"
507508
)
508-
min_weight_distance = trial.suggest_float(
509-
f"{component}.min_weight_distance",
510-
1.0,
511-
0.6 * last_layer_index,
509+
max_weight_position = max_weight_position_range.suggest_float(trial)
510+
511+
min_weight_range = settings.float_spec(f"{component}.min_weight")
512+
min_weight = min_weight_range.suggest_float(trial)
513+
514+
min_weight_distance_range = settings.float_spec(
515+
f"{component}.min_weight_distance"
512516
)
517+
min_weight_distance = min_weight_distance_range.suggest_float(trial)
513518

514519
parameters[component] = AbliterationParameters(
515520
max_weight=max_weight,
516-
max_weight_position=max_weight_position,
517-
min_weight=(min_weight * max_weight),
518-
min_weight_distance=min_weight_distance,
521+
max_weight_position=max_weight_position * last_layer_index,
522+
min_weight=min_weight * max_weight,
523+
min_weight_distance=min_weight_distance * last_layer_index,
519524
)
520525

521526
trial.set_user_attr("direction_index", direction_index)

src/heretic/model.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,8 @@ def abliterate(
401401
# the embedding matrix, but it's unclear if that has any benefits.
402402
for layer_index in range(len(self.get_layers())):
403403
for component, modules in self.get_layer_modules(layer_index).items():
404+
if component not in parameters:
405+
continue
404406
params = parameters[component]
405407

406408
# Type inference fails here for some reason.

0 commit comments

Comments
 (0)