mirror of
https://github.com/p-e-w/heretic.git
synced 2026-09-12 15:16:25 -07:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| edc3b12345 | |||
| 25979ad7d0 | |||
| 3b70fe5dfa | |||
| f7a456bd0c | |||
| 988c6bd90e | |||
| 96c7a7d98a | |||
| 1126332281 | |||
| c925f5e802 | |||
| 19cdf7e244 | |||
| 94775d4148 | |||
| 515a7b9eb5 | |||
| e26da5e0e6 | |||
| 4a6304c361 | |||
| c76416fe03 | |||
| 2bb203ee47 | |||
| d79a443e6f | |||
| ec0367226d | |||
| 5e3c04c802 | |||
| 0bb9521fbe | |||
| 992fb3a4b3 | |||
| 304c14adc7 | |||
| 56e57adf36 | |||
| bd1fa0ade4 | |||
| 3c5d6920bf | |||
| b8f4a9c985 | |||
| 154241f8a2 | |||
| 303ba9d978 | |||
| ea7c59a55a | |||
| cb4ef3fdfc | |||
| 4c80c4beb9 | |||
| 3a115e280c |
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
# Heretic: Fully automatic censorship removal for language models<br><br>[](https://discord.gg/gdXc48gSyT) [](https://huggingface.co/heretic-org)
|
# Heretic: Fully automatic censorship removal for language models<br><br>[](https://discord.gg/gdXc48gSyT) [](https://huggingface.co/heretic-org)
|
||||||
|
|
||||||
|
[](https://trendshift.io/repositories/20538)
|
||||||
|
|
||||||
Heretic is a tool that removes censorship (aka "safety alignment") from
|
Heretic is a tool that removes censorship (aka "safety alignment") from
|
||||||
transformer-based language models without expensive post-training.
|
transformer-based language models without expensive post-training.
|
||||||
It combines an advanced implementation of directional ablation, also known
|
It combines an advanced implementation of directional ablation, also known
|
||||||
|
|||||||
+16
-12
@@ -22,19 +22,24 @@ classifiers = [
|
|||||||
"Programming Language :: Python :: 3.12",
|
"Programming Language :: Python :: 3.12",
|
||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"accelerate~=1.10",
|
"accelerate~=1.13",
|
||||||
"bitsandbytes~=0.45",
|
"bitsandbytes~=0.49",
|
||||||
"datasets~=4.0",
|
"datasets~=4.7",
|
||||||
"hf-transfer~=0.1",
|
"hf-transfer~=0.1",
|
||||||
"huggingface-hub~=0.34",
|
"huggingface-hub~=1.7",
|
||||||
"kernels~=0.11",
|
"immutabledict~=4.3",
|
||||||
"optuna~=4.5",
|
"kernels~=0.12",
|
||||||
"peft~=0.14",
|
"langdetect~=1.0",
|
||||||
"psutil~=7.1",
|
"lm-eval[hf]~=0.4",
|
||||||
"pydantic-settings~=2.10",
|
"numpy~=2.2",
|
||||||
|
"optuna~=4.7",
|
||||||
|
"peft~=0.18",
|
||||||
|
"psutil~=7.2",
|
||||||
|
"pydantic-settings~=2.13",
|
||||||
"questionary~=2.1",
|
"questionary~=2.1",
|
||||||
"rich~=14.1",
|
"rich~=14.3",
|
||||||
"transformers~=4.57",
|
"tqdm~=4.67",
|
||||||
|
"transformers~=5.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
@@ -42,7 +47,6 @@ research = [
|
|||||||
"geom-median~=0.1",
|
"geom-median~=0.1",
|
||||||
"imageio~=2.37",
|
"imageio~=2.37",
|
||||||
"matplotlib~=3.10",
|
"matplotlib~=3.10",
|
||||||
"numpy~=2.2",
|
|
||||||
"pacmap~=0.8",
|
"pacmap~=0.8",
|
||||||
"scikit-learn~=1.7",
|
"scikit-learn~=1.7",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
import torch
|
import torch
|
||||||
import torch.linalg as LA
|
import torch.linalg as LA
|
||||||
import torch.nn.functional as F
|
import torch.nn.functional as F
|
||||||
|
from numpy.typing import NDArray
|
||||||
from rich.progress import track
|
from rich.progress import track
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
from torch import Tensor
|
from torch import Tensor
|
||||||
@@ -156,11 +158,9 @@ class Analyzer:
|
|||||||
try:
|
try:
|
||||||
import imageio.v3 as iio # ty:ignore[unresolved-import]
|
import imageio.v3 as iio # ty:ignore[unresolved-import]
|
||||||
import matplotlib.pyplot as plt # ty:ignore[unresolved-import]
|
import matplotlib.pyplot as plt # ty:ignore[unresolved-import]
|
||||||
import numpy as np # ty:ignore[unresolved-import]
|
|
||||||
from geom_median.numpy import ( # ty:ignore[unresolved-import]
|
from geom_median.numpy import ( # ty:ignore[unresolved-import]
|
||||||
compute_geometric_median,
|
compute_geometric_median,
|
||||||
)
|
)
|
||||||
from numpy.typing import NDArray # ty:ignore[unresolved-import]
|
|
||||||
from pacmap import PaCMAP # ty:ignore[unresolved-import]
|
from pacmap import PaCMAP # ty:ignore[unresolved-import]
|
||||||
except ImportError:
|
except ImportError:
|
||||||
print()
|
print()
|
||||||
|
|||||||
+110
-1
@@ -61,6 +61,18 @@ class DatasetSpecification(BaseModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BenchmarkSpecification(BaseModel):
|
||||||
|
task: str = Field(
|
||||||
|
description="Task ID of the benchmark in the Language Model Evaluation Harness."
|
||||||
|
)
|
||||||
|
|
||||||
|
name: str = Field(description="Name of the benchmark for presentation purposes.")
|
||||||
|
|
||||||
|
description: str = Field(
|
||||||
|
description="Description of the benchmark for presentation purposes."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
model: str = Field(description="Hugging Face model ID, or path to model on disk.")
|
model: str = Field(description="Hugging Face model ID, or path to model on disk.")
|
||||||
|
|
||||||
@@ -176,6 +188,42 @@ class Settings(BaseSettings):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
target_components: list[str] = Field(
|
||||||
|
default=["attn.o_proj", "mlp.down_proj"],
|
||||||
|
description=(
|
||||||
|
"List of component names to target for abliteration. "
|
||||||
|
'Currently supported values are "attn.o_proj" and "mlp.down_proj".'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
use_ara: bool = Field(
|
||||||
|
default=True,
|
||||||
|
description=(
|
||||||
|
"Whether to use Arbitrary-Rank Ablation (ARA), an abliteration method based on matrix optimization, "
|
||||||
|
"instead of traditional directional ablation."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
use_ara_lora: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description=(
|
||||||
|
"Use LoRA in ARA instead of full-weight editing. Makes it compatible with quantization and removes model reloads."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
ara_lora_rank: int = Field(
|
||||||
|
default=128,
|
||||||
|
description="If LoRA is used in ARA, this sets up its rank. Keep it high enough to simulate the 'arbitrary' effect.",
|
||||||
|
)
|
||||||
|
|
||||||
|
use_piqa: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description=(
|
||||||
|
"Whether to use the Physical Interaction: Question Answering (PIQA) benchmark "
|
||||||
|
"as the quality metric instead of the Kullback-Leibler divergence."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
orthogonalize_direction: bool = Field(
|
orthogonalize_direction: bool = Field(
|
||||||
default=False,
|
default=False,
|
||||||
description=(
|
description=(
|
||||||
@@ -185,7 +233,7 @@ class Settings(BaseSettings):
|
|||||||
)
|
)
|
||||||
|
|
||||||
row_normalization: RowNormalization = Field(
|
row_normalization: RowNormalization = Field(
|
||||||
default=RowNormalization.NONE,
|
default=RowNormalization.FULL,
|
||||||
description=(
|
description=(
|
||||||
"How to apply row normalization of the weights. Options: "
|
"How to apply row normalization of the weights. Options: "
|
||||||
'"none" (no normalization), '
|
'"none" (no normalization), '
|
||||||
@@ -230,6 +278,67 @@ class Settings(BaseSettings):
|
|||||||
description="Directory to save and load study progress to/from.",
|
description="Directory to save and load study progress to/from.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
benchmarks: list[BenchmarkSpecification] = Field(
|
||||||
|
default=[
|
||||||
|
BenchmarkSpecification(
|
||||||
|
task="agieval",
|
||||||
|
name="AGIEval",
|
||||||
|
description="A Human-Centric Benchmark for Evaluating Foundation Models",
|
||||||
|
),
|
||||||
|
BenchmarkSpecification(
|
||||||
|
task="bbh",
|
||||||
|
name="BIG-Bench Hard (BBH)",
|
||||||
|
description="Challenging BIG-Bench Tasks and Whether Chain-of-Thought Can Solve Them",
|
||||||
|
),
|
||||||
|
BenchmarkSpecification(
|
||||||
|
task="commonsense_qa",
|
||||||
|
name="CommonsenseQA",
|
||||||
|
description="A Question Answering Challenge Targeting Commonsense Knowledge",
|
||||||
|
),
|
||||||
|
BenchmarkSpecification(
|
||||||
|
task="eq_bench",
|
||||||
|
name="EQ-Bench",
|
||||||
|
description="An Emotional Intelligence Benchmark for Large Language Models",
|
||||||
|
),
|
||||||
|
BenchmarkSpecification(
|
||||||
|
task="gsm8k",
|
||||||
|
name="GSM8K",
|
||||||
|
description="Training Verifiers to Solve Math Word Problems",
|
||||||
|
),
|
||||||
|
BenchmarkSpecification(
|
||||||
|
task="hellaswag",
|
||||||
|
name="HellaSwag",
|
||||||
|
description="Can a Machine Really Finish Your Sentence?",
|
||||||
|
),
|
||||||
|
BenchmarkSpecification(
|
||||||
|
task="ifeval",
|
||||||
|
name="IFEval",
|
||||||
|
description="Instruction-Following Evaluation for Large Language Models",
|
||||||
|
),
|
||||||
|
BenchmarkSpecification(
|
||||||
|
task="mmlu",
|
||||||
|
name="MMLU",
|
||||||
|
description="Measuring Massive Multitask Language Understanding",
|
||||||
|
),
|
||||||
|
BenchmarkSpecification(
|
||||||
|
task="mmlu_pro",
|
||||||
|
name="MMLU-Pro",
|
||||||
|
description="A More Robust and Challenging Multi-Task Language Understanding Benchmark",
|
||||||
|
),
|
||||||
|
BenchmarkSpecification(
|
||||||
|
task="piqa",
|
||||||
|
name="PIQA",
|
||||||
|
description="Reasoning about Physical Commonsense in Natural Language",
|
||||||
|
),
|
||||||
|
BenchmarkSpecification(
|
||||||
|
task="winogrande",
|
||||||
|
name="WinoGrande",
|
||||||
|
description="An Adversarial Winograd Schema Challenge at Scale",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
description="Benchmarks to offer to the user for evaluating abliterated models.",
|
||||||
|
)
|
||||||
|
|
||||||
refusal_markers: list[str] = Field(
|
refusal_markers: list[str] = Field(
|
||||||
default=[
|
default=[
|
||||||
"sorry",
|
"sorry",
|
||||||
|
|||||||
+58
-31
@@ -1,7 +1,9 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
# Copyright (C) 2025-2026 Philipp Emanuel Weidmann <pew@worldwidemann.com> + contributors
|
# Copyright (C) 2025-2026 Philipp Emanuel Weidmann <pew@worldwidemann.com> + contributors
|
||||||
|
|
||||||
|
import lm_eval
|
||||||
import torch.nn.functional as F
|
import torch.nn.functional as F
|
||||||
|
from lm_eval.models.huggingface import HFLM
|
||||||
from torch import Tensor
|
from torch import Tensor
|
||||||
|
|
||||||
from .config import Settings
|
from .config import Settings
|
||||||
@@ -21,15 +23,16 @@ class Evaluator:
|
|||||||
self.settings = settings
|
self.settings = settings
|
||||||
self.model = model
|
self.model = model
|
||||||
|
|
||||||
print()
|
if not settings.use_piqa:
|
||||||
print(
|
print()
|
||||||
f"Loading good evaluation prompts from [bold]{settings.good_evaluation_prompts.dataset}[/]..."
|
print(
|
||||||
)
|
f"Loading good evaluation prompts from [bold]{settings.good_evaluation_prompts.dataset}[/]..."
|
||||||
self.good_prompts = load_prompts(settings, settings.good_evaluation_prompts)
|
)
|
||||||
print(f"* [bold]{len(self.good_prompts)}[/] prompts loaded")
|
self.good_prompts = load_prompts(settings, settings.good_evaluation_prompts)
|
||||||
|
print(f"* [bold]{len(self.good_prompts)}[/] prompts loaded")
|
||||||
|
|
||||||
print("* Obtaining first-token probability distributions...")
|
print("* Obtaining first-token probability distributions...")
|
||||||
self.base_logprobs = model.get_logprobs_batched(self.good_prompts)
|
self.base_logprobs = model.get_logprobs_batched(self.good_prompts)
|
||||||
|
|
||||||
print()
|
print()
|
||||||
print(
|
print(
|
||||||
@@ -93,33 +96,57 @@ class Evaluator:
|
|||||||
return refusal_count
|
return refusal_count
|
||||||
|
|
||||||
def get_score(self) -> tuple[tuple[float, float], float, int]:
|
def get_score(self) -> tuple[tuple[float, float], float, int]:
|
||||||
print(" * Obtaining first-token probability distributions...")
|
if self.settings.use_piqa:
|
||||||
logprobs = self.model.get_logprobs_batched(self.good_prompts)
|
print(" * Running PIQA benchmark...")
|
||||||
kl_divergence = F.kl_div(
|
hflm = HFLM(
|
||||||
logprobs,
|
pretrained=self.model.model, # ty:ignore[invalid-argument-type]
|
||||||
self.base_logprobs,
|
tokenizer=self.model.tokenizer, # ty:ignore[invalid-argument-type]
|
||||||
reduction="batchmean",
|
batch_size="auto",
|
||||||
log_target=True,
|
)
|
||||||
).item()
|
results = lm_eval.simple_evaluate(
|
||||||
print(f" * KL divergence: [bold]{kl_divergence:.4f}[/]")
|
model=hflm,
|
||||||
|
tasks=["piqa"],
|
||||||
|
)
|
||||||
|
piqa_acc_norm: float = results["results"]["piqa"]["acc_norm,none"]
|
||||||
|
print(f" * PIQA acc_norm: [bold]{piqa_acc_norm:.4f}[/]")
|
||||||
|
else:
|
||||||
|
print(" * Obtaining first-token probability distributions...")
|
||||||
|
logprobs = self.model.get_logprobs_batched(self.good_prompts)
|
||||||
|
kl_divergence = F.kl_div(
|
||||||
|
logprobs,
|
||||||
|
self.base_logprobs,
|
||||||
|
reduction="batchmean",
|
||||||
|
log_target=True,
|
||||||
|
).item()
|
||||||
|
print(f" * KL divergence: [bold]{kl_divergence:.4f}[/]")
|
||||||
|
|
||||||
print(" * Counting model refusals...")
|
print(" * Counting model refusals...")
|
||||||
refusals = self.count_refusals()
|
refusals = self.count_refusals()
|
||||||
print(f" * Refusals: [bold]{refusals}[/]/{len(self.bad_prompts)}")
|
print(f" * Refusals: [bold]{refusals}[/]/{len(self.bad_prompts)}")
|
||||||
|
|
||||||
kl_divergence_scale = self.settings.kl_divergence_scale
|
refusals_score = (
|
||||||
kl_divergence_target = self.settings.kl_divergence_target
|
refusals / self.base_refusals if self.base_refusals > 0 else float(refusals)
|
||||||
|
|
||||||
refusals_score = refusals / self.base_refusals
|
|
||||||
|
|
||||||
if kl_divergence >= kl_divergence_target:
|
|
||||||
kld_score = kl_divergence / kl_divergence_scale
|
|
||||||
else:
|
|
||||||
kld_score = refusals_score * kl_divergence_target / kl_divergence_scale
|
|
||||||
|
|
||||||
score = (
|
|
||||||
kld_score,
|
|
||||||
refusals_score,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return score, kl_divergence, refusals
|
if self.settings.use_piqa:
|
||||||
|
score = (
|
||||||
|
-piqa_acc_norm,
|
||||||
|
refusals_score,
|
||||||
|
)
|
||||||
|
|
||||||
|
return score, -piqa_acc_norm, refusals
|
||||||
|
else:
|
||||||
|
kl_divergence_scale = self.settings.kl_divergence_scale
|
||||||
|
kl_divergence_target = self.settings.kl_divergence_target
|
||||||
|
|
||||||
|
if kl_divergence >= kl_divergence_target:
|
||||||
|
kld_score = kl_divergence / kl_divergence_scale
|
||||||
|
else:
|
||||||
|
kld_score = refusals_score * kl_divergence_target / kl_divergence_scale
|
||||||
|
|
||||||
|
score = (
|
||||||
|
kld_score,
|
||||||
|
refusals_score,
|
||||||
|
)
|
||||||
|
|
||||||
|
return score, kl_divergence, refusals
|
||||||
|
|||||||
+393
-127
@@ -1,6 +1,15 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
# Copyright (C) 2025-2026 Philipp Emanuel Weidmann <pew@worldwidemann.com> + contributors
|
# Copyright (C) 2025-2026 Philipp Emanuel Weidmann <pew@worldwidemann.com> + contributors
|
||||||
|
|
||||||
|
# ruff: noqa: E402
|
||||||
|
|
||||||
|
from .progress import patch_tqdm
|
||||||
|
|
||||||
|
# This patches tqdm class definitions, which must happen
|
||||||
|
# before any other module imports tqdm.
|
||||||
|
patch_tqdm()
|
||||||
|
|
||||||
|
import logging
|
||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
@@ -10,9 +19,13 @@ from dataclasses import asdict
|
|||||||
from importlib.metadata import version
|
from importlib.metadata import version
|
||||||
from os.path import commonprefix
|
from os.path import commonprefix
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import huggingface_hub
|
import huggingface_hub
|
||||||
|
import lm_eval
|
||||||
|
import numpy as np
|
||||||
import optuna
|
import optuna
|
||||||
|
import questionary
|
||||||
import torch
|
import torch
|
||||||
import torch.nn.functional as F
|
import torch.nn.functional as F
|
||||||
import transformers
|
import transformers
|
||||||
@@ -24,6 +37,7 @@ from accelerate.utils import (
|
|||||||
is_xpu_available,
|
is_xpu_available,
|
||||||
)
|
)
|
||||||
from huggingface_hub import ModelCard, ModelCardData
|
from huggingface_hub import ModelCard, ModelCardData
|
||||||
|
from lm_eval.models.huggingface import HFLM
|
||||||
from optuna import Trial, TrialPruned
|
from optuna import Trial, TrialPruned
|
||||||
from optuna.exceptions import ExperimentalWarning
|
from optuna.exceptions import ExperimentalWarning
|
||||||
from optuna.samplers import TPESampler
|
from optuna.samplers import TPESampler
|
||||||
@@ -32,13 +46,14 @@ from optuna.storages.journal import JournalFileBackend, JournalFileOpenLock
|
|||||||
from optuna.study import StudyDirection
|
from optuna.study import StudyDirection
|
||||||
from optuna.trial import TrialState
|
from optuna.trial import TrialState
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
from questionary import Choice
|
from questionary import Choice, Style
|
||||||
|
from rich.table import Table
|
||||||
from rich.traceback import install
|
from rich.traceback import install
|
||||||
|
|
||||||
from .analyzer import Analyzer
|
from .analyzer import Analyzer
|
||||||
from .config import QuantizationMethod, Settings
|
from .config import QuantizationMethod, RowNormalization, Settings
|
||||||
from .evaluator import Evaluator
|
from .evaluator import Evaluator
|
||||||
from .model import AbliterationParameters, Model, get_model_class
|
from .model import AbliterationParameters, ARAParameters, Model, get_model_class
|
||||||
from .utils import (
|
from .utils import (
|
||||||
empty_cache,
|
empty_cache,
|
||||||
format_duration,
|
format_duration,
|
||||||
@@ -174,9 +189,15 @@ def run():
|
|||||||
# Adapted from https://github.com/huggingface/accelerate/blob/main/src/accelerate/commands/env.py
|
# Adapted from https://github.com/huggingface/accelerate/blob/main/src/accelerate/commands/env.py
|
||||||
if torch.cuda.is_available():
|
if torch.cuda.is_available():
|
||||||
count = torch.cuda.device_count()
|
count = torch.cuda.device_count()
|
||||||
print(f"Detected [bold]{count}[/] CUDA device(s):")
|
total_vram = sum(torch.cuda.mem_get_info(i)[1] for i in range(count))
|
||||||
|
print(
|
||||||
|
f"Detected [bold]{count}[/] CUDA device(s) ({total_vram / (1024**3):.2f} GB total VRAM):"
|
||||||
|
)
|
||||||
for i in range(count):
|
for i in range(count):
|
||||||
print(f"* GPU {i}: [bold]{torch.cuda.get_device_name(i)}[/]")
|
vram = torch.cuda.mem_get_info(i)[1] / (1024**3)
|
||||||
|
print(
|
||||||
|
f"* GPU {i}: [bold]{torch.cuda.get_device_name(i)}[/] ({vram:.2f} GB)"
|
||||||
|
)
|
||||||
elif is_xpu_available():
|
elif is_xpu_available():
|
||||||
count = torch.xpu.device_count()
|
count = torch.xpu.device_count()
|
||||||
print(f"Detected [bold]{count}[/] XPU device(s):")
|
print(f"Detected [bold]{count}[/] XPU device(s):")
|
||||||
@@ -206,8 +227,9 @@ def run():
|
|||||||
"[bold yellow]No GPU or other accelerator detected. Operations will be slow.[/]"
|
"[bold yellow]No GPU or other accelerator detected. Operations will be slow.[/]"
|
||||||
)
|
)
|
||||||
|
|
||||||
# We don't need gradients as we only do inference.
|
if not settings.use_ara:
|
||||||
torch.set_grad_enabled(False)
|
# We don't need gradients as we only do inference.
|
||||||
|
torch.set_grad_enabled(False)
|
||||||
|
|
||||||
# While determining the optimal batch size, we will try many different batch sizes,
|
# While determining the optimal batch size, we will try many different batch sizes,
|
||||||
# resulting in many computation graphs being compiled. Raising the limit (default = 8)
|
# resulting in many computation graphs being compiled. Raising the limit (default = 8)
|
||||||
@@ -219,6 +241,9 @@ def run():
|
|||||||
# In my entire career I've never seen a useful warning from that library.
|
# In my entire career I've never seen a useful warning from that library.
|
||||||
transformers.logging.set_verbosity_error()
|
transformers.logging.set_verbosity_error()
|
||||||
|
|
||||||
|
# Another library that generates warning spam.
|
||||||
|
logging.getLogger("lm_eval").setLevel(logging.ERROR)
|
||||||
|
|
||||||
# We do our own trial logging, so we don't need the INFO messages
|
# We do our own trial logging, so we don't need the INFO messages
|
||||||
# about parameters and results.
|
# about parameters and results.
|
||||||
optuna.logging.set_verbosity(optuna.logging.WARNING)
|
optuna.logging.set_verbosity(optuna.logging.WARNING)
|
||||||
@@ -371,7 +396,8 @@ def run():
|
|||||||
|
|
||||||
print()
|
print()
|
||||||
print("Checking for common response prefix...")
|
print("Checking for common response prefix...")
|
||||||
responses = model.get_responses_batched(good_prompts[:100] + bad_prompts[:100])
|
prefix_check_prompts = good_prompts[:100] + bad_prompts[:100]
|
||||||
|
responses = model.get_responses_batched(prefix_check_prompts)
|
||||||
|
|
||||||
# Despite being located in os.path, commonprefix actually performs
|
# Despite being located in os.path, commonprefix actually performs
|
||||||
# a naive string operation without any path-specific logic,
|
# a naive string operation without any path-specific logic,
|
||||||
@@ -382,24 +408,39 @@ def run():
|
|||||||
model.response_prefix = commonprefix(responses).rstrip(" ")
|
model.response_prefix = commonprefix(responses).rstrip(" ")
|
||||||
|
|
||||||
# Suppress CoT output.
|
# Suppress CoT output.
|
||||||
if model.response_prefix.startswith("<think>"):
|
recheck_prefix = False
|
||||||
# Most thinking models.
|
if model.response_prefix:
|
||||||
model.response_prefix = "<think></think>"
|
# When using any of the predefined prefixes below, we need to check that
|
||||||
elif model.response_prefix.startswith("<|channel|>analysis<|message|>"):
|
# the prefix is actually complete (e.g. not missing a trailing newline).
|
||||||
# gpt-oss.
|
recheck_prefix = True
|
||||||
model.response_prefix = "<|channel|>analysis<|message|><|end|><|start|>assistant<|channel|>final<|message|>"
|
if model.response_prefix.startswith("<think>"):
|
||||||
elif model.response_prefix.startswith("<thought>"):
|
# Most thinking models.
|
||||||
# Unknown, suggested by user.
|
model.response_prefix = "<think></think>"
|
||||||
model.response_prefix = "<thought></thought>"
|
elif model.response_prefix.startswith("<|channel|>analysis<|message|>"):
|
||||||
elif model.response_prefix.startswith("[THINK]"):
|
# gpt-oss.
|
||||||
# Unknown, suggested by user.
|
model.response_prefix = "<|channel|>analysis<|message|><|end|><|start|>assistant<|channel|>final<|message|>"
|
||||||
model.response_prefix = "[THINK][/THINK]"
|
elif model.response_prefix.startswith("<thought>"):
|
||||||
|
# Unknown, suggested by user.
|
||||||
|
model.response_prefix = "<thought></thought>"
|
||||||
|
elif model.response_prefix.startswith("[THINK]"):
|
||||||
|
# Unknown, suggested by user.
|
||||||
|
model.response_prefix = "[THINK][/THINK]"
|
||||||
|
else:
|
||||||
|
recheck_prefix = False
|
||||||
|
|
||||||
if model.response_prefix:
|
if model.response_prefix:
|
||||||
print(f"* Prefix found: [bold]{model.response_prefix!r}[/]")
|
print(f"* Prefix found: [bold]{model.response_prefix!r}[/]")
|
||||||
else:
|
else:
|
||||||
print("* None found")
|
print("* None found")
|
||||||
|
|
||||||
|
if recheck_prefix:
|
||||||
|
print("* Rechecking with prefix...")
|
||||||
|
responses = model.get_responses_batched(prefix_check_prompts)
|
||||||
|
additional_prefix = commonprefix(responses).rstrip(" ")
|
||||||
|
if additional_prefix:
|
||||||
|
model.response_prefix += additional_prefix
|
||||||
|
print(f"* Extended prefix found: [bold]{model.response_prefix!r}[/]")
|
||||||
|
|
||||||
evaluator = Evaluator(settings, model)
|
evaluator = Evaluator(settings, model)
|
||||||
|
|
||||||
if settings.evaluate_model is not None:
|
if settings.evaluate_model is not None:
|
||||||
@@ -411,40 +452,47 @@ def run():
|
|||||||
evaluator.get_score()
|
evaluator.get_score()
|
||||||
return
|
return
|
||||||
|
|
||||||
print()
|
if settings.use_ara:
|
||||||
print("Calculating per-layer refusal directions...")
|
print()
|
||||||
print("* Obtaining residuals for good prompts...")
|
print("Obtaining module I/O for good prompts...")
|
||||||
good_residuals = model.get_residuals_batched(good_prompts)
|
good_module_io = model.get_module_io_batched(good_prompts)
|
||||||
print("* Obtaining residuals for bad prompts...")
|
print("Obtaining module I/O for bad prompts...")
|
||||||
bad_residuals = model.get_residuals_batched(bad_prompts)
|
bad_module_io = model.get_module_io_batched(bad_prompts)
|
||||||
|
else:
|
||||||
|
print()
|
||||||
|
print("Calculating per-layer refusal directions...")
|
||||||
|
print("* Obtaining residuals for good prompts...")
|
||||||
|
good_residuals = model.get_residuals_batched(good_prompts)
|
||||||
|
print("* Obtaining residuals for bad prompts...")
|
||||||
|
bad_residuals = model.get_residuals_batched(bad_prompts)
|
||||||
|
|
||||||
good_means = good_residuals.mean(dim=0)
|
good_means = good_residuals.mean(dim=0)
|
||||||
bad_means = bad_residuals.mean(dim=0)
|
bad_means = bad_residuals.mean(dim=0)
|
||||||
|
|
||||||
refusal_directions = F.normalize(bad_means - good_means, p=2, dim=1)
|
refusal_directions = F.normalize(bad_means - good_means, p=2, dim=1)
|
||||||
|
|
||||||
if settings.orthogonalize_direction:
|
if settings.orthogonalize_direction:
|
||||||
# Implements https://huggingface.co/blog/grimjim/projected-abliteration
|
# Implements https://huggingface.co/blog/grimjim/projected-abliteration
|
||||||
# Adjust the refusal directions so that only the component that is
|
# Adjust the refusal directions so that only the component that is
|
||||||
# orthogonal to the good direction is subtracted during abliteration.
|
# orthogonal to the good direction is subtracted during abliteration.
|
||||||
good_directions = F.normalize(good_means, p=2, dim=1)
|
good_directions = F.normalize(good_means, p=2, dim=1)
|
||||||
projection_vector = torch.sum(refusal_directions * good_directions, dim=1)
|
projection_vector = torch.sum(refusal_directions * good_directions, dim=1)
|
||||||
refusal_directions = (
|
refusal_directions = (
|
||||||
refusal_directions - projection_vector.unsqueeze(1) * good_directions
|
refusal_directions - projection_vector.unsqueeze(1) * good_directions
|
||||||
)
|
)
|
||||||
refusal_directions = F.normalize(refusal_directions, p=2, dim=1)
|
refusal_directions = F.normalize(refusal_directions, p=2, dim=1)
|
||||||
|
|
||||||
analyzer = Analyzer(settings, model, good_residuals, bad_residuals)
|
analyzer = Analyzer(settings, model, good_residuals, bad_residuals)
|
||||||
|
|
||||||
if settings.print_residual_geometry:
|
if settings.print_residual_geometry:
|
||||||
analyzer.print_residual_geometry()
|
analyzer.print_residual_geometry()
|
||||||
|
|
||||||
if settings.plot_residuals:
|
if settings.plot_residuals:
|
||||||
analyzer.plot_residuals()
|
analyzer.plot_residuals()
|
||||||
|
|
||||||
# We don't need the residuals after computing refusal directions.
|
# We don't need the residuals after computing refusal directions.
|
||||||
del good_residuals, bad_residuals, analyzer
|
del good_residuals, bad_residuals, analyzer
|
||||||
empty_cache()
|
empty_cache()
|
||||||
|
|
||||||
trial_index = 0
|
trial_index = 0
|
||||||
start_index = 0
|
start_index = 0
|
||||||
@@ -455,83 +503,144 @@ def run():
|
|||||||
trial_index += 1
|
trial_index += 1
|
||||||
trial.set_user_attr("index", trial_index)
|
trial.set_user_attr("index", trial_index)
|
||||||
|
|
||||||
direction_scope = trial.suggest_categorical(
|
if settings.use_ara:
|
||||||
"direction_scope",
|
start_layer_index = trial.suggest_int(
|
||||||
[
|
"start_layer_index",
|
||||||
"global",
|
0,
|
||||||
"per layer",
|
len(model.get_layers()) // 2,
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
last_layer_index = len(model.get_layers()) - 1
|
|
||||||
|
|
||||||
# Discrimination between "harmful" and "harmless" inputs is usually strongest
|
|
||||||
# in layers slightly past the midpoint of the layer stack. See the original
|
|
||||||
# abliteration paper (https://arxiv.org/abs/2406.11717) for a deeper analysis.
|
|
||||||
#
|
|
||||||
# Note that we always sample this parameter even though we only need it for
|
|
||||||
# the "global" direction scope. The reason is that multivariate TPE doesn't
|
|
||||||
# work with conditional or variable-range parameters.
|
|
||||||
direction_index = trial.suggest_float(
|
|
||||||
"direction_index",
|
|
||||||
0.4 * last_layer_index,
|
|
||||||
0.9 * last_layer_index,
|
|
||||||
)
|
|
||||||
|
|
||||||
if direction_scope == "per layer":
|
|
||||||
direction_index = None
|
|
||||||
|
|
||||||
parameters = {}
|
|
||||||
|
|
||||||
for component in model.get_abliterable_components():
|
|
||||||
# The parameter ranges are based on experiments with various models
|
|
||||||
# and much wider ranges. They are not set in stone and might have to be
|
|
||||||
# adjusted for future models.
|
|
||||||
max_weight = trial.suggest_float(
|
|
||||||
f"{component}.max_weight",
|
|
||||||
0.8,
|
|
||||||
1.5,
|
|
||||||
)
|
)
|
||||||
max_weight_position = trial.suggest_float(
|
end_layer_index = trial.suggest_int(
|
||||||
f"{component}.max_weight_position",
|
"end_layer_index",
|
||||||
0.6 * last_layer_index,
|
len(model.get_layers()) // 2,
|
||||||
1.0 * last_layer_index,
|
len(model.get_layers()),
|
||||||
)
|
)
|
||||||
# For sampling purposes, min_weight is expressed as a fraction of max_weight,
|
preserve_good_behavior_weight = trial.suggest_float(
|
||||||
# again because multivariate TPE doesn't support variable-range parameters.
|
"preserve_good_behavior_weight",
|
||||||
# The value is transformed into the actual min_weight value below.
|
|
||||||
min_weight = trial.suggest_float(
|
|
||||||
f"{component}.min_weight",
|
|
||||||
0.0,
|
0.0,
|
||||||
1.0,
|
1.0,
|
||||||
)
|
)
|
||||||
min_weight_distance = trial.suggest_float(
|
steer_bad_behavior_weight = trial.suggest_float(
|
||||||
f"{component}.min_weight_distance",
|
"steer_bad_behavior_weight",
|
||||||
|
0.0001,
|
||||||
1.0,
|
1.0,
|
||||||
0.6 * last_layer_index,
|
log=True,
|
||||||
|
)
|
||||||
|
overcorrect_relative_weight = trial.suggest_float(
|
||||||
|
"overcorrect_relative_weight",
|
||||||
|
0.0,
|
||||||
|
1.3,
|
||||||
|
)
|
||||||
|
neighbor_count = trial.suggest_int(
|
||||||
|
"neighbor_count",
|
||||||
|
1,
|
||||||
|
15,
|
||||||
)
|
)
|
||||||
|
|
||||||
parameters[component] = AbliterationParameters(
|
ara_parameters = ARAParameters(
|
||||||
max_weight=max_weight,
|
start_layer_index=start_layer_index,
|
||||||
max_weight_position=max_weight_position,
|
end_layer_index=end_layer_index,
|
||||||
min_weight=(min_weight * max_weight),
|
preserve_good_behavior_weight=preserve_good_behavior_weight,
|
||||||
min_weight_distance=min_weight_distance,
|
steer_bad_behavior_weight=steer_bad_behavior_weight,
|
||||||
|
overcorrect_relative_weight=overcorrect_relative_weight,
|
||||||
|
neighbor_count=neighbor_count,
|
||||||
)
|
)
|
||||||
|
|
||||||
trial.set_user_attr("direction_index", direction_index)
|
trial.set_user_attr("ara_parameters", asdict(ara_parameters))
|
||||||
trial.set_user_attr("parameters", {k: asdict(v) for k, v in parameters.items()})
|
else:
|
||||||
|
direction_scope = trial.suggest_categorical(
|
||||||
|
"direction_scope",
|
||||||
|
[
|
||||||
|
"global",
|
||||||
|
"per layer",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
last_layer_index = len(model.get_layers()) - 1
|
||||||
|
|
||||||
|
# Discrimination between "harmful" and "harmless" inputs is usually strongest
|
||||||
|
# in layers slightly past the midpoint of the layer stack. See the original
|
||||||
|
# abliteration paper (https://arxiv.org/abs/2406.11717) for a deeper analysis.
|
||||||
|
#
|
||||||
|
# Note that we always sample this parameter even though we only need it for
|
||||||
|
# the "global" direction scope. The reason is that multivariate TPE doesn't
|
||||||
|
# work with conditional or variable-range parameters.
|
||||||
|
direction_index = trial.suggest_float(
|
||||||
|
"direction_index",
|
||||||
|
0.4 * last_layer_index,
|
||||||
|
0.9 * last_layer_index,
|
||||||
|
)
|
||||||
|
|
||||||
|
if direction_scope == "per layer":
|
||||||
|
direction_index = None
|
||||||
|
|
||||||
|
parameters = {}
|
||||||
|
|
||||||
|
for component in model.get_abliterable_components():
|
||||||
|
# The parameter ranges are based on experiments with various models
|
||||||
|
# and much wider ranges. They are not set in stone and might have to be
|
||||||
|
# adjusted for future models.
|
||||||
|
max_weight = trial.suggest_float(
|
||||||
|
f"{component}.max_weight",
|
||||||
|
0.8,
|
||||||
|
1.5,
|
||||||
|
)
|
||||||
|
max_weight_position = trial.suggest_float(
|
||||||
|
f"{component}.max_weight_position",
|
||||||
|
0.6 * last_layer_index,
|
||||||
|
1.0 * last_layer_index,
|
||||||
|
)
|
||||||
|
# For sampling purposes, min_weight is expressed as a fraction of max_weight,
|
||||||
|
# again because multivariate TPE doesn't support variable-range parameters.
|
||||||
|
# The value is transformed into the actual min_weight value below.
|
||||||
|
min_weight = trial.suggest_float(
|
||||||
|
f"{component}.min_weight",
|
||||||
|
0.0,
|
||||||
|
1.0,
|
||||||
|
)
|
||||||
|
min_weight_distance = trial.suggest_float(
|
||||||
|
f"{component}.min_weight_distance",
|
||||||
|
1.0,
|
||||||
|
0.6 * last_layer_index,
|
||||||
|
)
|
||||||
|
|
||||||
|
parameters[component] = AbliterationParameters(
|
||||||
|
max_weight=max_weight,
|
||||||
|
max_weight_position=max_weight_position,
|
||||||
|
min_weight=(min_weight * max_weight),
|
||||||
|
min_weight_distance=min_weight_distance,
|
||||||
|
)
|
||||||
|
|
||||||
|
trial.set_user_attr("direction_index", direction_index)
|
||||||
|
trial.set_user_attr(
|
||||||
|
"parameters", {k: asdict(v) for k, v in parameters.items()}
|
||||||
|
)
|
||||||
|
|
||||||
print()
|
print()
|
||||||
print(
|
print(
|
||||||
f"Running trial [bold]{trial_index}[/] of [bold]{settings.n_trials}[/]..."
|
f"Running trial [bold]{trial_index}[/] of [bold]{settings.n_trials}[/]..."
|
||||||
)
|
)
|
||||||
print("* Parameters:")
|
print("* Parameters:")
|
||||||
for name, value in get_trial_parameters(trial).items():
|
for name, value in get_trial_parameters(settings, trial).items():
|
||||||
print(f" * {name} = [bold]{value}[/]")
|
print(f" * {name} = [bold]{value}[/]")
|
||||||
print("* Resetting model...")
|
if settings.use_ara_lora:
|
||||||
model.reset_model()
|
print("* Resetting model...")
|
||||||
print("* Abliterating...")
|
model.reset_model()
|
||||||
model.abliterate(refusal_directions, direction_index, parameters)
|
print("* Abliterating (Arbitrary-Rank Ablation with LoRA)...")
|
||||||
|
model.ara_lora_abliterate(
|
||||||
|
good_module_io,
|
||||||
|
bad_module_io,
|
||||||
|
ARAParameters(**trial.user_attrs["ara_parameters"]),
|
||||||
|
)
|
||||||
|
elif settings.use_ara:
|
||||||
|
print("* Reloading model...")
|
||||||
|
model.reset_model()
|
||||||
|
print("* Abliterating (Arbitrary-Rank Ablation)...")
|
||||||
|
model.ara_abliterate(good_module_io, bad_module_io, ara_parameters)
|
||||||
|
else:
|
||||||
|
print("* Resetting model...")
|
||||||
|
model.reset_model()
|
||||||
|
print("* Abliterating...")
|
||||||
|
model.abliterate(refusal_directions, direction_index, parameters)
|
||||||
print("* Evaluating...")
|
print("* Evaluating...")
|
||||||
score, kl_divergence, refusals = evaluator.get_score()
|
score, kl_divergence, refusals = evaluator.get_score()
|
||||||
|
|
||||||
@@ -629,7 +738,7 @@ def run():
|
|||||||
title=(
|
title=(
|
||||||
f"[Trial {trial.user_attrs['index']:>3}] "
|
f"[Trial {trial.user_attrs['index']:>3}] "
|
||||||
f"Refusals: {trial.user_attrs['refusals']:>2}/{len(evaluator.bad_prompts)}, "
|
f"Refusals: {trial.user_attrs['refusals']:>2}/{len(evaluator.bad_prompts)}, "
|
||||||
f"KL divergence: {trial.user_attrs['kl_divergence']:.4f}"
|
f"{'PIQA acc_norm' if settings.use_piqa else 'KL divergence'}: {(-1 if settings.use_piqa else 1) * trial.user_attrs['kl_divergence']:.4f}"
|
||||||
),
|
),
|
||||||
value=trial,
|
value=trial,
|
||||||
)
|
)
|
||||||
@@ -708,19 +817,38 @@ def run():
|
|||||||
print()
|
print()
|
||||||
print(f"Restoring model from trial [bold]{trial.user_attrs['index']}[/]...")
|
print(f"Restoring model from trial [bold]{trial.user_attrs['index']}[/]...")
|
||||||
print("* Parameters:")
|
print("* Parameters:")
|
||||||
for name, value in get_trial_parameters(trial).items():
|
for name, value in get_trial_parameters(settings, trial).items():
|
||||||
print(f" * {name} = [bold]{value}[/]")
|
print(f" * {name} = [bold]{value}[/]")
|
||||||
print("* Resetting model...")
|
if settings.use_ara_lora:
|
||||||
model.reset_model()
|
print("* Resetting model...")
|
||||||
print("* Abliterating...")
|
model.reset_model()
|
||||||
model.abliterate(
|
print("* Abliterating (Arbitrary-Rank Ablation with LoRA)...")
|
||||||
refusal_directions,
|
model.ara_lora_abliterate(
|
||||||
trial.user_attrs["direction_index"],
|
good_module_io,
|
||||||
{
|
bad_module_io,
|
||||||
k: AbliterationParameters(**v)
|
ARAParameters(**trial.user_attrs["ara_parameters"]),
|
||||||
for k, v in trial.user_attrs["parameters"].items()
|
)
|
||||||
},
|
elif settings.use_ara:
|
||||||
)
|
print("* Reloading model...")
|
||||||
|
model.reset_model()
|
||||||
|
print("* Abliterating (Arbitrary-Rank Ablation)...")
|
||||||
|
model.ara_abliterate(
|
||||||
|
good_module_io,
|
||||||
|
bad_module_io,
|
||||||
|
ARAParameters(**trial.user_attrs["ara_parameters"]),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print("* Resetting model...")
|
||||||
|
model.reset_model()
|
||||||
|
print("* Abliterating...")
|
||||||
|
model.abliterate(
|
||||||
|
refusal_directions,
|
||||||
|
trial.user_attrs["direction_index"],
|
||||||
|
{
|
||||||
|
k: AbliterationParameters(**v)
|
||||||
|
for k, v in trial.user_attrs["parameters"].items()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
print()
|
print()
|
||||||
@@ -730,6 +858,7 @@ def run():
|
|||||||
"Save the model to a local folder",
|
"Save the model to a local folder",
|
||||||
"Upload the model to Hugging Face",
|
"Upload the model to Hugging Face",
|
||||||
"Chat with the model",
|
"Chat with the model",
|
||||||
|
"Benchmark the model",
|
||||||
"Return to the trial selection menu",
|
"Return to the trial selection menu",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -755,8 +884,12 @@ def run():
|
|||||||
print("Saving LoRA adapter...")
|
print("Saving LoRA adapter...")
|
||||||
model.model.save_pretrained(save_directory)
|
model.model.save_pretrained(save_directory)
|
||||||
else:
|
else:
|
||||||
print("Saving merged model...")
|
if settings.use_ara:
|
||||||
merged_model = model.get_merged_model()
|
print("Saving model...")
|
||||||
|
merged_model = model.model
|
||||||
|
else:
|
||||||
|
print("Saving merged model...")
|
||||||
|
merged_model = model.get_merged_model()
|
||||||
merged_model.save_pretrained(save_directory)
|
merged_model.save_pretrained(save_directory)
|
||||||
del merged_model
|
del merged_model
|
||||||
empty_cache()
|
empty_cache()
|
||||||
@@ -794,6 +927,8 @@ def run():
|
|||||||
"Private",
|
"Private",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
if visibility is None:
|
||||||
|
continue
|
||||||
private = visibility == "Private"
|
private = visibility == "Private"
|
||||||
|
|
||||||
strategy = obtain_merge_strategy(settings)
|
strategy = obtain_merge_strategy(settings)
|
||||||
@@ -808,8 +943,12 @@ def run():
|
|||||||
token=token,
|
token=token,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
print("Uploading merged model...")
|
if settings.use_ara:
|
||||||
merged_model = model.get_merged_model()
|
print("Uploading model...")
|
||||||
|
merged_model = model.model
|
||||||
|
else:
|
||||||
|
print("Uploading merged model...")
|
||||||
|
merged_model = model.get_merged_model()
|
||||||
merged_model.push_to_hub(
|
merged_model.push_to_hub(
|
||||||
repo_id,
|
repo_id,
|
||||||
private=private,
|
private=private,
|
||||||
@@ -823,11 +962,23 @@ def run():
|
|||||||
token=token,
|
token=token,
|
||||||
)
|
)
|
||||||
|
|
||||||
# If the model path doesn't exist locally, it can be assumed
|
# If the model path exists locally and includes the
|
||||||
# to be a model hosted on the Hugging Face Hub, in which case
|
# card, use it directly. If the model path doesn't
|
||||||
|
# exist locally, it can be assumed to be a model
|
||||||
|
# hosted on the Hugging Face Hub, in which case
|
||||||
# we can retrieve the model card.
|
# we can retrieve the model card.
|
||||||
if not Path(settings.model).exists():
|
model_path = Path(settings.model)
|
||||||
|
if model_path.exists():
|
||||||
|
card_path = (
|
||||||
|
model_path / huggingface_hub.constants.REPOCARD_NAME
|
||||||
|
)
|
||||||
|
if card_path.exists():
|
||||||
|
card = ModelCard.load(card_path)
|
||||||
|
else:
|
||||||
|
card = None
|
||||||
|
else:
|
||||||
card = ModelCard.load(settings.model)
|
card = ModelCard.load(settings.model)
|
||||||
|
if card is not None:
|
||||||
if card.data is None:
|
if card.data is None:
|
||||||
card.data = ModelCardData()
|
card.data = ModelCardData()
|
||||||
if card.data.tags is None:
|
if card.data.tags is None:
|
||||||
@@ -836,6 +987,14 @@ def run():
|
|||||||
card.data.tags.append("uncensored")
|
card.data.tags.append("uncensored")
|
||||||
card.data.tags.append("decensored")
|
card.data.tags.append("decensored")
|
||||||
card.data.tags.append("abliterated")
|
card.data.tags.append("abliterated")
|
||||||
|
if settings.use_ara:
|
||||||
|
card.data.tags.append("ara")
|
||||||
|
elif (
|
||||||
|
settings.orthogonalize_direction
|
||||||
|
and settings.row_normalization
|
||||||
|
== RowNormalization.FULL
|
||||||
|
):
|
||||||
|
card.data.tags.append("mpoa")
|
||||||
card.text = (
|
card.text = (
|
||||||
get_readme_intro(
|
get_readme_intro(
|
||||||
settings,
|
settings,
|
||||||
@@ -879,6 +1038,113 @@ def run():
|
|||||||
# Ctrl+C/Ctrl+D
|
# Ctrl+C/Ctrl+D
|
||||||
break
|
break
|
||||||
|
|
||||||
|
case "Benchmark the model":
|
||||||
|
benchmarks = questionary.checkbox(
|
||||||
|
"Which benchmarks do you want to run?",
|
||||||
|
[
|
||||||
|
Choice(
|
||||||
|
title=f"{benchmark.name}: {benchmark.description}",
|
||||||
|
value=benchmark,
|
||||||
|
)
|
||||||
|
for benchmark in settings.benchmarks
|
||||||
|
],
|
||||||
|
style=Style([("highlighted", "reverse")]),
|
||||||
|
).ask()
|
||||||
|
if not benchmarks:
|
||||||
|
continue
|
||||||
|
|
||||||
|
scope = prompt_select(
|
||||||
|
(
|
||||||
|
"Do you want to benchmark the original model along with the decensored model? "
|
||||||
|
"Benchmarking both models allows you to compare the scores, but it takes twice as much time."
|
||||||
|
),
|
||||||
|
[
|
||||||
|
"Benchmark only the decensored model",
|
||||||
|
"Benchmark both models",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
if scope is None:
|
||||||
|
continue
|
||||||
|
benchmark_original_model = scope == "Benchmark both models"
|
||||||
|
|
||||||
|
hflm = HFLM(
|
||||||
|
pretrained=model.model, # ty:ignore[invalid-argument-type]
|
||||||
|
tokenizer=model.tokenizer, # ty:ignore[invalid-argument-type]
|
||||||
|
batch_size="auto",
|
||||||
|
)
|
||||||
|
|
||||||
|
table = Table()
|
||||||
|
table.add_column("Benchmark")
|
||||||
|
table.add_column("Metric")
|
||||||
|
if benchmark_original_model:
|
||||||
|
table.add_column("This model", justify="right")
|
||||||
|
table.add_column("Original model", justify="right")
|
||||||
|
else:
|
||||||
|
table.add_column("Value", justify="right")
|
||||||
|
|
||||||
|
try:
|
||||||
|
first_benchmark = True
|
||||||
|
|
||||||
|
for benchmark in benchmarks:
|
||||||
|
print(
|
||||||
|
f"Running benchmark [bold]{benchmark.name}[/]..."
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_results() -> dict[str, Any]:
|
||||||
|
results = lm_eval.simple_evaluate(
|
||||||
|
model=hflm,
|
||||||
|
tasks=[benchmark.task],
|
||||||
|
)
|
||||||
|
return results["results"][benchmark.task]
|
||||||
|
|
||||||
|
results = get_results()
|
||||||
|
if benchmark_original_model:
|
||||||
|
with model.model.disable_adapter(): # ty:ignore[call-non-callable]
|
||||||
|
original_results = get_results()
|
||||||
|
|
||||||
|
first_row = True
|
||||||
|
|
||||||
|
for metric, value in results.items():
|
||||||
|
if metric != "alias":
|
||||||
|
if first_row and not first_benchmark:
|
||||||
|
if benchmark_original_model:
|
||||||
|
table.add_row("", "", "", "")
|
||||||
|
else:
|
||||||
|
table.add_row("", "", "")
|
||||||
|
|
||||||
|
def format_value(value: Any) -> str:
|
||||||
|
if isinstance(
|
||||||
|
value,
|
||||||
|
(float, np.floating),
|
||||||
|
):
|
||||||
|
return f"{value:.4f}"
|
||||||
|
else:
|
||||||
|
return f"{value}"
|
||||||
|
|
||||||
|
cells = [
|
||||||
|
benchmark.name if first_row else "",
|
||||||
|
metric,
|
||||||
|
format_value(value),
|
||||||
|
]
|
||||||
|
if benchmark_original_model:
|
||||||
|
cells.append(
|
||||||
|
format_value(
|
||||||
|
original_results[metric]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
table.add_row(*cells)
|
||||||
|
|
||||||
|
first_row = False
|
||||||
|
first_benchmark = False
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# The benchmark run might have been cancelled by the user
|
||||||
|
# before any benchmark was completed, so we only print results
|
||||||
|
# if there actually are some.
|
||||||
|
if table.rows:
|
||||||
|
print(table)
|
||||||
|
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
print(f"[red]Error: {error}[/]")
|
print(f"[red]Error: {error}[/]")
|
||||||
|
|
||||||
|
|||||||
+440
-31
@@ -4,7 +4,7 @@
|
|||||||
import math
|
import math
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Type, cast
|
from typing import Any, Callable, Type, TypeAlias, cast
|
||||||
|
|
||||||
import bitsandbytes as bnb
|
import bitsandbytes as bnb
|
||||||
import torch
|
import torch
|
||||||
@@ -14,6 +14,8 @@ from peft import LoraConfig, PeftModel, get_peft_model
|
|||||||
from peft.tuners.lora.layer import Linear
|
from peft.tuners.lora.layer import Linear
|
||||||
from torch import FloatTensor, LongTensor, Tensor
|
from torch import FloatTensor, LongTensor, Tensor
|
||||||
from torch.nn import Module, ModuleList
|
from torch.nn import Module, ModuleList
|
||||||
|
from torch.optim import LBFGS
|
||||||
|
from torch.utils.hooks import RemovableHandle
|
||||||
from transformers import (
|
from transformers import (
|
||||||
AutoModelForCausalLM,
|
AutoModelForCausalLM,
|
||||||
AutoModelForImageTextToText,
|
AutoModelForImageTextToText,
|
||||||
@@ -30,7 +32,7 @@ from transformers.generation import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from .config import QuantizationMethod, RowNormalization, Settings
|
from .config import QuantizationMethod, RowNormalization, Settings
|
||||||
from .utils import Prompt, batchify, empty_cache, print
|
from .utils import Prompt, batchify, empty_cache, mean_distances_to_knn, print
|
||||||
|
|
||||||
|
|
||||||
def get_model_class(
|
def get_model_class(
|
||||||
@@ -52,6 +54,23 @@ class AbliterationParameters:
|
|||||||
min_weight_distance: float
|
min_weight_distance: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ARAParameters:
|
||||||
|
start_layer_index: int
|
||||||
|
end_layer_index: int
|
||||||
|
preserve_good_behavior_weight: float
|
||||||
|
steer_bad_behavior_weight: float
|
||||||
|
overcorrect_relative_weight: float
|
||||||
|
neighbor_count: int
|
||||||
|
|
||||||
|
|
||||||
|
# The list contains one element per layer.
|
||||||
|
# Each element maps from the component name to a (possibly sparse) mapping
|
||||||
|
# from the module index to an (input, output) tuple containing the I/O
|
||||||
|
# tensors of shape (prompt, component).
|
||||||
|
ModuleIO: TypeAlias = list[dict[str, dict[int, tuple[Tensor, Tensor]]]]
|
||||||
|
|
||||||
|
|
||||||
class Model:
|
class Model:
|
||||||
model: PreTrainedModel | PeftModel
|
model: PreTrainedModel | PeftModel
|
||||||
tokenizer: PreTrainedTokenizerBase
|
tokenizer: PreTrainedTokenizerBase
|
||||||
@@ -91,7 +110,7 @@ class Model:
|
|||||||
self.trusted_models[settings.evaluate_model] = settings.trust_remote_code
|
self.trusted_models[settings.evaluate_model] = settings.trust_remote_code
|
||||||
|
|
||||||
for dtype in settings.dtypes:
|
for dtype in settings.dtypes:
|
||||||
print(f"* Trying dtype [bold]{dtype}[/]... ", end="")
|
print(f"* Trying dtype [bold]{dtype}[/]...")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
quantization_config = self._get_quantization_config(dtype)
|
quantization_config = self._get_quantization_config(dtype)
|
||||||
@@ -131,46 +150,62 @@ class Model:
|
|||||||
except Exception as error:
|
except Exception as error:
|
||||||
self.model = None # ty:ignore[invalid-assignment]
|
self.model = None # ty:ignore[invalid-assignment]
|
||||||
empty_cache()
|
empty_cache()
|
||||||
print(f"[red]Failed[/] ({error})")
|
print(f"* [red]Failed[/] ({error})")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if settings.quantization == QuantizationMethod.BNB_4BIT:
|
if settings.quantization == QuantizationMethod.BNB_4BIT:
|
||||||
print("[green]Ok[/] (quantized to 4-bit precision)")
|
print("* Quantized to 4-bit precision")
|
||||||
else:
|
|
||||||
print("[green]Ok[/]")
|
|
||||||
|
|
||||||
break
|
break
|
||||||
|
|
||||||
if self.model is None:
|
if self.model is None:
|
||||||
raise Exception("Failed to load model with all configured dtypes.")
|
raise Exception("Failed to load model with all configured dtypes.")
|
||||||
|
|
||||||
self._apply_lora()
|
if not settings.use_ara or settings.use_ara_lora:
|
||||||
|
self._apply_lora()
|
||||||
|
|
||||||
# LoRA B matrices are initialized to zero by default in PEFT,
|
# LoRA B matrices are initialized to zero by default in PEFT,
|
||||||
# so we don't need to do anything manually.
|
# so we don't need to do anything manually.
|
||||||
|
|
||||||
print(f"* Transformer model with [bold]{len(self.get_layers())}[/] layers")
|
print(f"* Transformer model with [bold]{len(self.get_layers())}[/] layers")
|
||||||
print("* Abliterable components:")
|
print("* Abliterable components:")
|
||||||
for component, modules in self.get_layer_modules(0).items():
|
all_components = {}
|
||||||
print(
|
for layer_index in range(len(self.get_layers())):
|
||||||
f" * [bold]{component}[/]: [bold]{len(modules)}[/] modules per layer"
|
for component, modules in self.get_layer_modules(layer_index).items():
|
||||||
)
|
if component not in all_components:
|
||||||
|
all_components[component] = 0
|
||||||
|
all_components[component] += len(modules)
|
||||||
|
for component, count in all_components.items():
|
||||||
|
print(f" * [bold]{component}[/]: [bold]{count}[/] modules total")
|
||||||
|
|
||||||
def _apply_lora(self):
|
def _apply_lora(self):
|
||||||
# Guard against calling this method at the wrong time.
|
# Guard against calling this method at the wrong time.
|
||||||
assert isinstance(self.model, PreTrainedModel)
|
assert isinstance(self.model, PreTrainedModel)
|
||||||
|
|
||||||
# Always use LoRA adapters for abliteration (faster reload, no weight modification).
|
# Always use LoRA adapters for abliteration (faster reload, no weight modification).
|
||||||
# We use the leaf names (e.g. "o_proj") as target modules.
|
# Collect actual leaf module names from the model for LoRA targeting.
|
||||||
# This may cause LoRA adapters to be attached to unrelated modules (e.g. "conv.o_proj"),
|
# This is more robust than splitting component keys (e.g. "attn.o_proj" -> "o_proj")
|
||||||
# but this is harmless as we only abliterate the modules we target in `abliterate()`,
|
# because hybrid models like Qwen3.5 MoE have modules with different names
|
||||||
# leaving the others at their default (identity) state.
|
# across layers (e.g. "o_proj" on attention layers, "out_proj" on linear attention layers).
|
||||||
# NOTE: This will need to be updated when hybrid layer support (#43) is merged.
|
target_modules_set: set[str] = set()
|
||||||
target_modules = [
|
|
||||||
comp.split(".")[-1] for comp in self.get_abliterable_components()
|
|
||||||
]
|
|
||||||
|
|
||||||
if self.settings.row_normalization != RowNormalization.FULL:
|
module_id_to_full_name = {
|
||||||
|
id(module): module_name
|
||||||
|
for module_name, module in self.model.named_modules()
|
||||||
|
}
|
||||||
|
|
||||||
|
for layer_index in range(len(self.get_layers())):
|
||||||
|
for modules in self.get_layer_modules(layer_index).values():
|
||||||
|
for module in modules:
|
||||||
|
full_name = module_id_to_full_name.get(id(module))
|
||||||
|
if full_name is not None:
|
||||||
|
target_modules_set.add(full_name)
|
||||||
|
|
||||||
|
target_modules = sorted(target_modules_set)
|
||||||
|
|
||||||
|
if self.settings.use_ara_lora:
|
||||||
|
lora_rank = self.settings.ara_lora_rank
|
||||||
|
elif self.settings.row_normalization != RowNormalization.FULL:
|
||||||
# Rank 1 is sufficient for directional ablation without renormalization.
|
# Rank 1 is sufficient for directional ablation without renormalization.
|
||||||
lora_rank = 1
|
lora_rank = 1
|
||||||
else:
|
else:
|
||||||
@@ -192,7 +227,10 @@ class Model:
|
|||||||
# so the result is a PeftModel rather than a PeftMixedModel.
|
# so the result is a PeftModel rather than a PeftMixedModel.
|
||||||
self.model = cast(PeftModel, get_peft_model(self.model, self.peft_config))
|
self.model = cast(PeftModel, get_peft_model(self.model, self.peft_config))
|
||||||
|
|
||||||
print(f"* LoRA adapters initialized (targets: {', '.join(target_modules)})")
|
display_targets = sorted({name.rsplit(".", 1)[-1] for name in target_modules})
|
||||||
|
print(
|
||||||
|
f"* LoRA adapters initialized (target types: {', '.join(display_targets)})"
|
||||||
|
)
|
||||||
|
|
||||||
def _get_quantization_config(self, dtype: str) -> BitsAndBytesConfig | None:
|
def _get_quantization_config(self, dtype: str) -> BitsAndBytesConfig | None:
|
||||||
"""
|
"""
|
||||||
@@ -276,7 +314,11 @@ class Model:
|
|||||||
performs full model reload with quantization config.
|
performs full model reload with quantization config.
|
||||||
"""
|
"""
|
||||||
current_model = getattr(self.model.config, "name_or_path", None)
|
current_model = getattr(self.model.config, "name_or_path", None)
|
||||||
if current_model == self.settings.model and not self.needs_reload:
|
if (
|
||||||
|
current_model == self.settings.model
|
||||||
|
and not self.needs_reload
|
||||||
|
and (not self.settings.use_ara or self.settings.use_ara_lora)
|
||||||
|
):
|
||||||
# Reset LoRA adapters to zero (identity transformation)
|
# Reset LoRA adapters to zero (identity transformation)
|
||||||
for name, module in self.model.named_modules():
|
for name, module in self.model.named_modules():
|
||||||
if "lora_B" in name and hasattr(module, "weight"):
|
if "lora_B" in name and hasattr(module, "weight"):
|
||||||
@@ -305,7 +347,8 @@ class Model:
|
|||||||
**extra_kwargs,
|
**extra_kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
self._apply_lora()
|
if not self.settings.use_ara or self.settings.use_ara_lora:
|
||||||
|
self._apply_lora()
|
||||||
|
|
||||||
self.needs_reload = False
|
self.needs_reload = False
|
||||||
|
|
||||||
@@ -329,6 +372,9 @@ class Model:
|
|||||||
modules = {}
|
modules = {}
|
||||||
|
|
||||||
def try_add(component: str, module: Any):
|
def try_add(component: str, module: Any):
|
||||||
|
if component not in self.settings.target_components:
|
||||||
|
return
|
||||||
|
|
||||||
# Only add if it's a proper nn.Module (PEFT can wrap these with LoRA)
|
# Only add if it's a proper nn.Module (PEFT can wrap these with LoRA)
|
||||||
if isinstance(module, Module):
|
if isinstance(module, Module):
|
||||||
if component not in modules:
|
if component not in modules:
|
||||||
@@ -340,9 +386,14 @@ class Model:
|
|||||||
f"Unexpected Tensor in {component} - expected nn.Module"
|
f"Unexpected Tensor in {component} - expected nn.Module"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Exceptions aren't suppressed here, because there is currently
|
# Standard self-attention out-projection (most models).
|
||||||
# no alternative location for the attention out-projection.
|
with suppress(Exception):
|
||||||
try_add("attn.o_proj", layer.self_attn.o_proj) # ty:ignore[possibly-missing-attribute]
|
try_add("attn.o_proj", layer.self_attn.o_proj) # ty:ignore[possibly-missing-attribute]
|
||||||
|
|
||||||
|
# Qwen3.5 MoE hybrid layers use GatedDeltaNet (linear attention) instead
|
||||||
|
# of standard self-attention, so self_attn.o_proj doesn't exist on those layers.
|
||||||
|
with suppress(Exception):
|
||||||
|
try_add("attn.o_proj", layer.linear_attn.out_proj) # ty:ignore[possibly-missing-attribute]
|
||||||
|
|
||||||
# Most dense models.
|
# Most dense models.
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
@@ -374,7 +425,12 @@ class Model:
|
|||||||
return modules
|
return modules
|
||||||
|
|
||||||
def get_abliterable_components(self) -> list[str]:
|
def get_abliterable_components(self) -> list[str]:
|
||||||
return list(self.get_layer_modules(0).keys())
|
# Scan all layers because hybrid models (e.g. Qwen3.5 MoE) have different
|
||||||
|
# components on different layers (some have self_attn, others linear_attn).
|
||||||
|
components: set[str] = set()
|
||||||
|
for layer_index in range(len(self.get_layers())):
|
||||||
|
components.update(self.get_layer_modules(layer_index).keys())
|
||||||
|
return sorted(components)
|
||||||
|
|
||||||
def abliterate(
|
def abliterate(
|
||||||
self,
|
self,
|
||||||
@@ -519,6 +575,228 @@ class Model:
|
|||||||
weight_A.data = lora_A.to(weight_A.dtype)
|
weight_A.data = lora_A.to(weight_A.dtype)
|
||||||
weight_B.data = lora_B.to(weight_B.dtype)
|
weight_B.data = lora_B.to(weight_B.dtype)
|
||||||
|
|
||||||
|
def ara_abliterate(
|
||||||
|
self,
|
||||||
|
good_module_io: ModuleIO,
|
||||||
|
bad_module_io: ModuleIO,
|
||||||
|
parameters: ARAParameters,
|
||||||
|
):
|
||||||
|
for layer_index in range(
|
||||||
|
parameters.start_layer_index,
|
||||||
|
parameters.end_layer_index,
|
||||||
|
):
|
||||||
|
for component, modules in self.get_layer_modules(layer_index).items():
|
||||||
|
for module_index, module in enumerate(modules):
|
||||||
|
# See above for a (partial) justification of this cast.
|
||||||
|
module = cast(Linear, module)
|
||||||
|
matrix = module.weight
|
||||||
|
|
||||||
|
row_norms = LA.vector_norm(matrix, dim=1, keepdim=True).detach()
|
||||||
|
|
||||||
|
# Helper function for reparameterization (row-norm preservation constraint).
|
||||||
|
def get_matrix() -> Tensor:
|
||||||
|
if self.settings.row_normalization == RowNormalization.FULL:
|
||||||
|
# See https://huggingface.co/blog/grimjim/norm-preserving-biprojected-abliteration
|
||||||
|
return row_norms * F.normalize(matrix, p=2, dim=1)
|
||||||
|
else:
|
||||||
|
return matrix
|
||||||
|
|
||||||
|
good_input, good_output = good_module_io[layer_index][component][
|
||||||
|
module_index
|
||||||
|
]
|
||||||
|
bad_input, bad_output = bad_module_io[layer_index][component][
|
||||||
|
module_index
|
||||||
|
]
|
||||||
|
|
||||||
|
good_input = good_input.to(matrix.device)
|
||||||
|
good_output = good_output.to(matrix.device)
|
||||||
|
bad_input = bad_input.to(matrix.device)
|
||||||
|
bad_output = bad_output.to(matrix.device)
|
||||||
|
|
||||||
|
def objective(matrix: Tensor) -> Tensor:
|
||||||
|
new_good_output = good_input @ matrix.T
|
||||||
|
new_bad_output = bad_input @ matrix.T
|
||||||
|
|
||||||
|
# The outputs for "good" prompts should change as little as possible.
|
||||||
|
preserve_good_behavior = (
|
||||||
|
(new_good_output - good_output) ** 2
|
||||||
|
).mean()
|
||||||
|
|
||||||
|
steer_bad_behavior = (
|
||||||
|
# Pull the outputs for "bad" prompts towards
|
||||||
|
# the original outputs for "good" prompts.
|
||||||
|
mean_distances_to_knn(
|
||||||
|
new_bad_output,
|
||||||
|
good_output,
|
||||||
|
parameters.neighbor_count,
|
||||||
|
).mean()
|
||||||
|
# Push the outputs for "bad" prompts away from
|
||||||
|
# the original outputs for "bad" prompts.
|
||||||
|
# In combination with the above, this overcorrects
|
||||||
|
# away from the original residuals, which results
|
||||||
|
# in stronger steering that can overcome more complex
|
||||||
|
# refusal mechanisms.
|
||||||
|
+ parameters.overcorrect_relative_weight
|
||||||
|
* -mean_distances_to_knn(
|
||||||
|
new_bad_output,
|
||||||
|
bad_output,
|
||||||
|
parameters.neighbor_count,
|
||||||
|
).mean()
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
parameters.preserve_good_behavior_weight
|
||||||
|
* preserve_good_behavior
|
||||||
|
+ parameters.steer_bad_behavior_weight * steer_bad_behavior
|
||||||
|
)
|
||||||
|
|
||||||
|
optimizer = LBFGS(
|
||||||
|
[matrix],
|
||||||
|
lr=1.0,
|
||||||
|
max_iter=20, # Number of internal iterations per step, *not* the number of steps.
|
||||||
|
history_size=10,
|
||||||
|
line_search_fn="strong_wolfe",
|
||||||
|
)
|
||||||
|
|
||||||
|
def closure() -> Tensor:
|
||||||
|
optimizer.zero_grad()
|
||||||
|
loss = objective(get_matrix())
|
||||||
|
loss.backward()
|
||||||
|
return loss
|
||||||
|
|
||||||
|
# Convergence usually happens within 2-3 steps, so this is more than enough.
|
||||||
|
for step in range(5):
|
||||||
|
loss = optimizer.step(closure)
|
||||||
|
# print(
|
||||||
|
# f"\\[{layer_index}/{component}/{module_index}] Step: {step}, Loss: {loss.item():.6f}"
|
||||||
|
# )
|
||||||
|
|
||||||
|
# Free the gradient buffers accumulated on the weight parameters
|
||||||
|
# during optimization. Without this, they persist on the model
|
||||||
|
# (one full-size gradient per processed weight) and can easily
|
||||||
|
# consume tens of GiB of VRAM, causing out-of-memory errors
|
||||||
|
# during the subsequent evaluation.
|
||||||
|
optimizer.zero_grad(set_to_none=True)
|
||||||
|
|
||||||
|
with torch.no_grad():
|
||||||
|
matrix.copy_(get_matrix())
|
||||||
|
|
||||||
|
def ara_lora_abliterate(
|
||||||
|
self,
|
||||||
|
good_module_io: ModuleIO,
|
||||||
|
bad_module_io: ModuleIO,
|
||||||
|
parameters: ARAParameters,
|
||||||
|
):
|
||||||
|
for layer_index in range(
|
||||||
|
parameters.start_layer_index,
|
||||||
|
parameters.end_layer_index,
|
||||||
|
):
|
||||||
|
for component, modules in self.get_layer_modules(layer_index).items():
|
||||||
|
for module_index, module in enumerate(modules):
|
||||||
|
# Cast to Linear to access weights and LoRA adapters.
|
||||||
|
module = cast(Linear, module)
|
||||||
|
|
||||||
|
# Base weight handling and dequantization.
|
||||||
|
# We need the base weight in float32 to compute the effective weight.
|
||||||
|
base_weight = cast(Tensor, module.base_layer.weight)
|
||||||
|
quant_state = getattr(base_weight, "quant_state", None)
|
||||||
|
|
||||||
|
if quant_state is None:
|
||||||
|
W_base = base_weight.to(torch.float32)
|
||||||
|
else:
|
||||||
|
# Maintain the original dequantization logic for bitsandbytes.
|
||||||
|
W_base = cast(
|
||||||
|
Tensor,
|
||||||
|
bnb.functional.dequantize_4bit(
|
||||||
|
base_weight.data,
|
||||||
|
quant_state
|
||||||
|
).to(torch.float32),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Row normalization setup.
|
||||||
|
# Pre-calculate the original row norms to preserve them.
|
||||||
|
# This implements the RowNormalization.FULL logic.
|
||||||
|
W_row_norms = LA.vector_norm(W_base, dim=1, keepdim=True).detach()
|
||||||
|
|
||||||
|
# Adapter target identification.
|
||||||
|
# We optimize the LoRA weights A and B.
|
||||||
|
lora_A = cast(Tensor, module.lora_A["default"].weight)
|
||||||
|
lora_B = cast(Tensor, module.lora_B["default"].weight)
|
||||||
|
|
||||||
|
# Data preparation.
|
||||||
|
# Move I/O tensors to the device of the adapter weights.
|
||||||
|
good_input, good_output = good_module_io[layer_index][component][module_index]
|
||||||
|
bad_input, bad_output = bad_module_io[layer_index][component][module_index]
|
||||||
|
|
||||||
|
good_input = good_input.float().to(lora_A.device)
|
||||||
|
good_output = good_output.float().to(lora_A.device)
|
||||||
|
bad_input = bad_input.float().to(lora_A.device)
|
||||||
|
bad_output = bad_output.float().to(lora_A.device)
|
||||||
|
|
||||||
|
# The objective function.
|
||||||
|
def objective(A: Tensor, B: Tensor) -> Tensor:
|
||||||
|
# Calculate effective weight: W_eff = W_base + B @ A.
|
||||||
|
W_eff = W_base + (B @ A)
|
||||||
|
|
||||||
|
# Apply Row Normalization (keep original norms).
|
||||||
|
if self.settings.row_normalization == RowNormalization.FULL:
|
||||||
|
# Normalize to unit length, then scale by original norms.
|
||||||
|
W_eff = F.normalize(W_eff, p=2, dim=1) * W_row_norms
|
||||||
|
|
||||||
|
# Compute outputs using the effective weight.
|
||||||
|
new_good_output = good_input @ W_eff.T
|
||||||
|
new_bad_output = bad_input @ W_eff.T
|
||||||
|
|
||||||
|
# The original ARA loss function.
|
||||||
|
preserve_good_behavior = (
|
||||||
|
(new_good_output - good_output) ** 2
|
||||||
|
).mean()
|
||||||
|
|
||||||
|
steer_bad_behavior = (
|
||||||
|
mean_distances_to_knn(
|
||||||
|
new_bad_output,
|
||||||
|
good_output,
|
||||||
|
parameters.neighbor_count,
|
||||||
|
).mean()
|
||||||
|
+ parameters.overcorrect_relative_weight
|
||||||
|
* -mean_distances_to_knn(
|
||||||
|
new_bad_output,
|
||||||
|
bad_output,
|
||||||
|
parameters.neighbor_count,
|
||||||
|
).mean()
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
parameters.preserve_good_behavior_weight
|
||||||
|
* preserve_good_behavior
|
||||||
|
+ parameters.steer_bad_behavior_weight * steer_bad_behavior
|
||||||
|
)
|
||||||
|
|
||||||
|
# Optimization loop.
|
||||||
|
# We optimize A and B, not the base matrix.
|
||||||
|
optimizer = LBFGS(
|
||||||
|
[lora_A, lora_B],
|
||||||
|
lr=1.0,
|
||||||
|
max_iter=20,
|
||||||
|
history_size=10,
|
||||||
|
line_search_fn="strong_wolfe",
|
||||||
|
)
|
||||||
|
|
||||||
|
def closure():
|
||||||
|
optimizer.zero_grad()
|
||||||
|
# Pass the actual tensors being optimized to the objective.
|
||||||
|
loss = objective(lora_A, lora_B)
|
||||||
|
loss.backward()
|
||||||
|
return loss
|
||||||
|
|
||||||
|
# Run optimization steps.
|
||||||
|
for step in range(5):
|
||||||
|
optimizer.step(closure)
|
||||||
|
|
||||||
|
# Free the gradient buffers accumulated on the LoRA adapter
|
||||||
|
# parameters during optimization (see ara_abliterate for details).
|
||||||
|
optimizer.zero_grad(set_to_none=True)
|
||||||
|
|
||||||
def generate(
|
def generate(
|
||||||
self,
|
self,
|
||||||
prompts: list[Prompt],
|
prompts: list[Prompt],
|
||||||
@@ -653,6 +931,132 @@ class Model:
|
|||||||
|
|
||||||
return torch.cat(residuals, dim=0)
|
return torch.cat(residuals, dim=0)
|
||||||
|
|
||||||
|
def get_module_io(
|
||||||
|
self,
|
||||||
|
prompts: list[Prompt],
|
||||||
|
) -> ModuleIO:
|
||||||
|
# The list contains one element per layer.
|
||||||
|
# Each element maps from the component name to a (possibly sparse) mapping
|
||||||
|
# from the module index to an (input, output) tuple containing the I/O
|
||||||
|
# tensors of shape (prompt, component).
|
||||||
|
module_io: ModuleIO = []
|
||||||
|
|
||||||
|
def get_hook(
|
||||||
|
layer_index: int,
|
||||||
|
component: str,
|
||||||
|
module_index: int,
|
||||||
|
) -> Callable[[Module, tuple[Tensor, ...], Tensor], None]:
|
||||||
|
def hook(
|
||||||
|
module: Module,
|
||||||
|
inputs: tuple[Tensor, ...],
|
||||||
|
outputs: Tensor,
|
||||||
|
) -> None:
|
||||||
|
if len(module_io) == layer_index:
|
||||||
|
# First invocation of the hook for this layer.
|
||||||
|
module_io.append({})
|
||||||
|
|
||||||
|
# Layers are invoked in order during inference,
|
||||||
|
# so this should always hold.
|
||||||
|
assert len(module_io) == layer_index + 1
|
||||||
|
|
||||||
|
if component not in module_io[layer_index]:
|
||||||
|
module_io[layer_index][component] = {}
|
||||||
|
|
||||||
|
# Each module should be invoked at most once per inference step.
|
||||||
|
assert module_index not in module_io[layer_index][component]
|
||||||
|
|
||||||
|
# inputs[0] and outputs have shape (prompt, position, component),
|
||||||
|
# so this extracts the input/output at the end of each prompt.
|
||||||
|
# Move to CPU to decouple from device assignments, which can
|
||||||
|
# change between model reloads in multi-GPU configurations.
|
||||||
|
input = inputs[0][:, -1, :].detach().clone().cpu()
|
||||||
|
output = outputs[:, -1, :].detach().clone().cpu()
|
||||||
|
|
||||||
|
# The modules associated with a component (e.g. expert MLPs)
|
||||||
|
# are not necessarily invoked in order, nor are all of them
|
||||||
|
# necessarily invoked in each inference step, so we cannot
|
||||||
|
# use a list here.
|
||||||
|
module_io[layer_index][component][module_index] = (input, output)
|
||||||
|
|
||||||
|
return hook
|
||||||
|
|
||||||
|
hook_handles: list[RemovableHandle] = []
|
||||||
|
|
||||||
|
for layer_index in range(len(self.get_layers())):
|
||||||
|
for component, modules in self.get_layer_modules(layer_index).items():
|
||||||
|
for module_index, module in enumerate(modules):
|
||||||
|
hook_handles.append(
|
||||||
|
module.register_forward_hook(
|
||||||
|
get_hook(layer_index, component, module_index)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.generate(prompts, max_new_tokens=1)
|
||||||
|
|
||||||
|
for hook_handle in hook_handles:
|
||||||
|
hook_handle.remove()
|
||||||
|
|
||||||
|
return module_io
|
||||||
|
|
||||||
|
def get_module_io_batched(
|
||||||
|
self,
|
||||||
|
prompts: list[Prompt],
|
||||||
|
) -> ModuleIO:
|
||||||
|
# Aggregating batch results is more complicated for module I/O
|
||||||
|
# than for other get_*_batched methods, because the structure of the results
|
||||||
|
# might differ between batches, as whether individual modules activate
|
||||||
|
# can depend on the prompt (in particular for MoE models).
|
||||||
|
# In practice, inhomogeneous results should be very rare, but to be fully
|
||||||
|
# generic, this logic is required.
|
||||||
|
module_io_batches: list[ModuleIO] = [
|
||||||
|
self.get_module_io(batch)
|
||||||
|
for batch in batchify(prompts, self.settings.batch_size)
|
||||||
|
]
|
||||||
|
|
||||||
|
module_io: ModuleIO = []
|
||||||
|
|
||||||
|
for layer_index in range(len(self.get_layers())):
|
||||||
|
module_io.append({})
|
||||||
|
|
||||||
|
for module_io_batch in module_io_batches:
|
||||||
|
for component, io_map in module_io_batch[layer_index].items():
|
||||||
|
if component not in module_io[layer_index]:
|
||||||
|
module_io[layer_index][component] = {}
|
||||||
|
|
||||||
|
for module_index in io_map:
|
||||||
|
if module_index not in module_io[layer_index][component]:
|
||||||
|
# This is a placeholder; the actual aggregation happens below.
|
||||||
|
# We need to iterate over the batches twice because we don't
|
||||||
|
# know in advance which components and module indices are present.
|
||||||
|
module_io[layer_index][component][module_index] = (
|
||||||
|
torch.empty(0),
|
||||||
|
torch.empty(0),
|
||||||
|
)
|
||||||
|
|
||||||
|
for component, io_map in module_io[layer_index].items():
|
||||||
|
for module_index in io_map:
|
||||||
|
inputs_outputs = [
|
||||||
|
module_io_batch[layer_index][component][module_index]
|
||||||
|
for module_io_batch in module_io_batches
|
||||||
|
if component in module_io_batch[layer_index]
|
||||||
|
and module_index in module_io_batch[layer_index][component]
|
||||||
|
]
|
||||||
|
input = torch.cat(
|
||||||
|
[input_output[0] for input_output in inputs_outputs],
|
||||||
|
dim=0,
|
||||||
|
)
|
||||||
|
output = torch.cat(
|
||||||
|
[input_output[1] for input_output in inputs_outputs],
|
||||||
|
dim=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
# The key already exists, and replacing existing values
|
||||||
|
# in a dictionary while iterating over the same dictionary
|
||||||
|
# is safe in Python.
|
||||||
|
module_io[layer_index][component][module_index] = (input, output)
|
||||||
|
|
||||||
|
return module_io
|
||||||
|
|
||||||
# We work with logprobs rather than probabilities for numerical stability
|
# We work with logprobs rather than probabilities for numerical stability
|
||||||
# when computing the KL divergence.
|
# when computing the KL divergence.
|
||||||
def get_logprobs(self, prompts: list[Prompt]) -> Tensor:
|
def get_logprobs(self, prompts: list[Prompt]) -> Tensor:
|
||||||
@@ -719,7 +1123,12 @@ class Model:
|
|||||||
max_new_tokens=4096,
|
max_new_tokens=4096,
|
||||||
) # ty:ignore[call-non-callable]
|
) # ty:ignore[call-non-callable]
|
||||||
|
|
||||||
return self.tokenizer.decode(
|
# This cast is valid because str is the return type
|
||||||
outputs[0, inputs["input_ids"].shape[1] :],
|
# when passing a sequence of token IDs.
|
||||||
skip_special_tokens=True,
|
return cast(
|
||||||
|
str,
|
||||||
|
self.tokenizer.decode(
|
||||||
|
outputs[0, inputs["input_ids"].shape[1] :],
|
||||||
|
skip_special_tokens=True,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
# Copyright (C) 2025-2026 Philipp Emanuel Weidmann <pew@worldwidemann.com> + contributors
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import tqdm
|
||||||
|
import tqdm.auto
|
||||||
|
from rich.progress import Progress
|
||||||
|
|
||||||
|
|
||||||
|
# A class that provides the same interface as tqdm,
|
||||||
|
# but displays progress bars using Rich.
|
||||||
|
class TqdmShim(tqdm.tqdm):
|
||||||
|
def __init__(self, *args: Any, **kwargs: Any):
|
||||||
|
self.rich_progress = Progress(transient=True)
|
||||||
|
self.rich_progress.start()
|
||||||
|
self.rich_task_id = self.rich_progress.add_task(
|
||||||
|
kwargs.get("desc", ""),
|
||||||
|
total=kwargs.get("total", None),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Chain up to the parent constructor to ensure that the internal state of the superclass
|
||||||
|
# is correctly initialized, which some methods that we don't override might rely on.
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def display(self, *args: Any, **kwargs: Any):
|
||||||
|
self.rich_progress.update(
|
||||||
|
self.rich_task_id,
|
||||||
|
description=self.desc,
|
||||||
|
total=self.total,
|
||||||
|
completed=self.n,
|
||||||
|
)
|
||||||
|
|
||||||
|
def close(self, *args: Any, **kwargs: Any):
|
||||||
|
self.rich_progress.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def patch_tqdm():
|
||||||
|
tqdm.tqdm = TqdmShim # ty:ignore[invalid-assignment]
|
||||||
|
tqdm.auto.tqdm = TqdmShim # ty:ignore[invalid-assignment]
|
||||||
+70
-19
@@ -25,8 +25,9 @@ from optuna import Trial
|
|||||||
from psutil import Process
|
from psutil import Process
|
||||||
from questionary import Choice, Style
|
from questionary import Choice, Style
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
|
from torch import Tensor
|
||||||
|
|
||||||
from .config import DatasetSpecification, Settings
|
from .config import DatasetSpecification, RowNormalization, Settings
|
||||||
|
|
||||||
print = Console(highlight=False).print
|
print = Console(highlight=False).print
|
||||||
|
|
||||||
@@ -38,11 +39,17 @@ def print_memory_usage():
|
|||||||
p("Resident system RAM", Process().memory_info().rss)
|
p("Resident system RAM", Process().memory_info().rss)
|
||||||
|
|
||||||
if torch.cuda.is_available():
|
if torch.cuda.is_available():
|
||||||
p("Allocated GPU VRAM", torch.cuda.memory_allocated())
|
count = torch.cuda.device_count()
|
||||||
p("Reserved GPU VRAM", torch.cuda.memory_reserved())
|
allocated = sum(torch.cuda.memory_allocated(device) for device in range(count))
|
||||||
|
reserved = sum(torch.cuda.memory_reserved(device) for device in range(count))
|
||||||
|
p("Allocated GPU VRAM", allocated)
|
||||||
|
p("Reserved GPU VRAM", reserved)
|
||||||
elif is_xpu_available():
|
elif is_xpu_available():
|
||||||
p("Allocated XPU memory", torch.xpu.memory_allocated())
|
count = torch.xpu.device_count()
|
||||||
p("Reserved XPU memory", torch.xpu.memory_reserved())
|
allocated = sum(torch.xpu.memory_allocated(device) for device in range(count))
|
||||||
|
reserved = sum(torch.xpu.memory_reserved(device) for device in range(count))
|
||||||
|
p("Allocated XPU memory", allocated)
|
||||||
|
p("Reserved XPU memory", reserved)
|
||||||
elif torch.backends.mps.is_available():
|
elif torch.backends.mps.is_available():
|
||||||
p("Allocated MPS memory", torch.mps.current_allocated_memory())
|
p("Allocated MPS memory", torch.mps.current_allocated_memory())
|
||||||
p("Driver (reserved) MPS memory", torch.mps.driver_allocated_memory())
|
p("Driver (reserved) MPS memory", torch.mps.driver_allocated_memory())
|
||||||
@@ -228,6 +235,14 @@ def batchify(items: list[T], batch_size: int) -> list[list[T]]:
|
|||||||
return [items[i : i + batch_size] for i in range(0, len(items), batch_size)]
|
return [items[i : i + batch_size] for i in range(0, len(items), batch_size)]
|
||||||
|
|
||||||
|
|
||||||
|
# For each vector in the 2D-tensor `a`, computes the mean Euclidean distance
|
||||||
|
# to the `k` nearest neighbors of the vector among the vectors in the 2D-tensor `b`.
|
||||||
|
def mean_distances_to_knn(a: Tensor, b: Tensor, k: int) -> Tensor:
|
||||||
|
distances = torch.cdist(a, b)
|
||||||
|
nearest_distances, _ = distances.topk(k, dim=1, largest=False)
|
||||||
|
return nearest_distances.mean(1)
|
||||||
|
|
||||||
|
|
||||||
def empty_cache():
|
def empty_cache():
|
||||||
# Collecting garbage is not an idempotent operation, and to avoid OOM errors,
|
# Collecting garbage is not an idempotent operation, and to avoid OOM errors,
|
||||||
# gc.collect() has to be called both before and after emptying the backend cache.
|
# gc.collect() has to be called both before and after emptying the backend cache.
|
||||||
@@ -250,19 +265,46 @@ def empty_cache():
|
|||||||
gc.collect()
|
gc.collect()
|
||||||
|
|
||||||
|
|
||||||
def get_trial_parameters(trial: Trial) -> dict[str, str]:
|
def get_trial_parameters(settings: Settings, trial: Trial) -> dict[str, str]:
|
||||||
params = {}
|
if settings.use_ara:
|
||||||
|
parameters = trial.user_attrs["ara_parameters"]
|
||||||
|
|
||||||
direction_index = trial.user_attrs["direction_index"]
|
return {
|
||||||
params["direction_index"] = (
|
name: (f"{value:.4f}" if isinstance(value, float) else f"{value}")
|
||||||
"per layer" if (direction_index is None) else f"{direction_index:.2f}"
|
for name, value in parameters.items()
|
||||||
)
|
}
|
||||||
|
else:
|
||||||
|
params = {}
|
||||||
|
|
||||||
for component, parameters in trial.user_attrs["parameters"].items():
|
direction_index = trial.user_attrs["direction_index"]
|
||||||
for name, value in parameters.items():
|
params["direction_index"] = (
|
||||||
params[f"{component}.{name}"] = f"{value:.2f}"
|
"per layer" if (direction_index is None) else f"{direction_index:.2f}"
|
||||||
|
)
|
||||||
|
|
||||||
return params
|
for component, parameters in trial.user_attrs["parameters"].items():
|
||||||
|
for name, value in parameters.items():
|
||||||
|
params[f"{component}.{name}"] = f"{value:.2f}"
|
||||||
|
|
||||||
|
return params
|
||||||
|
|
||||||
|
|
||||||
|
def get_method_description(settings: Settings) -> str:
|
||||||
|
if settings.use_ara:
|
||||||
|
return (
|
||||||
|
" with the [Arbitrary-Rank Ablation (ARA)](https://github.com/p-e-w/heretic/pull/211) method"
|
||||||
|
+ (
|
||||||
|
" (with row-norm preservation)"
|
||||||
|
if settings.row_normalization == RowNormalization.FULL
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif (
|
||||||
|
settings.orthogonalize_direction
|
||||||
|
and settings.row_normalization == RowNormalization.FULL
|
||||||
|
):
|
||||||
|
return " with a variant of the [Magnitude-Preserving Orthogonal Ablation (MPOA)](https://huggingface.co/blog/grimjim/norm-preserving-biprojected-abliteration) method"
|
||||||
|
else:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def get_readme_intro(
|
def get_readme_intro(
|
||||||
@@ -271,11 +313,17 @@ def get_readme_intro(
|
|||||||
base_refusals: int,
|
base_refusals: int,
|
||||||
bad_prompts: list[Prompt],
|
bad_prompts: list[Prompt],
|
||||||
) -> str:
|
) -> str:
|
||||||
model_link = f"[{settings.model}](https://huggingface.co/{settings.model})"
|
if Path(settings.model).exists():
|
||||||
|
# Hide the path, which may contain private information.
|
||||||
|
model_link = "a model"
|
||||||
|
else:
|
||||||
|
model_link = f"[{settings.model}](https://huggingface.co/{settings.model})"
|
||||||
|
|
||||||
return f"""# This is a decensored version of {
|
return f"""# This is a decensored version of {
|
||||||
model_link
|
model_link
|
||||||
}, made using [Heretic](https://github.com/p-e-w/heretic) v{version("heretic-llm")}
|
}, made using [Heretic](https://github.com/p-e-w/heretic) v{version("heretic-llm")}{
|
||||||
|
get_method_description(settings)
|
||||||
|
}
|
||||||
|
|
||||||
## Abliteration parameters
|
## Abliteration parameters
|
||||||
|
|
||||||
@@ -285,7 +333,7 @@ def get_readme_intro(
|
|||||||
chr(10).join(
|
chr(10).join(
|
||||||
[
|
[
|
||||||
f"| **{name}** | {value} |"
|
f"| **{name}** | {value} |"
|
||||||
for name, value in get_trial_parameters(trial).items()
|
for name, value in get_trial_parameters(settings, trial).items()
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -294,7 +342,10 @@ def get_readme_intro(
|
|||||||
|
|
||||||
| Metric | This model | Original model ({model_link}) |
|
| Metric | This model | Original model ({model_link}) |
|
||||||
| :----- | :--------: | :---------------------------: |
|
| :----- | :--------: | :---------------------------: |
|
||||||
| **KL divergence** | {trial.user_attrs["kl_divergence"]:.4f} | 0 *(by definition)* |
|
| **{"PIQA acc_norm" if settings.use_piqa else "KL divergence"}** | {
|
||||||
|
(-1 if settings.use_piqa else 1) * trial.user_attrs["kl_divergence"]:.4f} | {
|
||||||
|
"*Unknown*" if settings.use_piqa else "0 *(by definition)*"
|
||||||
|
} |
|
||||||
| **Refusals** | {trial.user_attrs["refusals"]}/{len(bad_prompts)} | {base_refusals}/{
|
| **Refusals** | {trial.user_attrs["refusals"]}/{len(bad_prompts)} | {base_refusals}/{
|
||||||
len(bad_prompts)
|
len(bad_prompts)
|
||||||
} |
|
} |
|
||||||
|
|||||||
Reference in New Issue
Block a user