9 Commits

Author SHA1 Message Date
Philipp Emanuel Weidmann 9b7624ddfa build: bump version to 1.3.0 2026-05-05 18:22:02 +05:30
Philipp Emanuel Weidmann 0e7c14d94a fix: minor cleanups and improvements 2026-05-04 22:11:14 +05:30
Philipp Emanuel Weidmann 02ce8ad079 chore: update dependencies 2026-05-03 19:25:36 +05:30
Philipp Emanuel Weidmann 79ea9ce905 docs: update README 2026-05-03 09:08:57 +05:30
Philipp Emanuel Weidmann 216c089974 Revert "Revert "fix: disable LoRA export for now" (#308)" (#319)
This reverts commit da92f745de.
2026-05-03 07:25:00 +05:30
Philipp Emanuel Weidmann 43f8e86a84 fix: minor cleanups and improvements 2026-05-02 06:35:31 +05:30
anrp da92f745de Revert "fix: disable LoRA export for now" (#308)
This reverts commit 025ab3a881.

Co-authored-by: Andrew Patrikalakis <anrp@tri.global>
2026-05-02 06:07:47 +05:30
dependabot[bot] ebb5e651df build(deps): bump mako from 1.3.10 to 1.3.11 (#309)
Bumps [mako](https://github.com/sqlalchemy/mako) from 1.3.10 to 1.3.11.
- [Release notes](https://github.com/sqlalchemy/mako/releases)
- [Changelog](https://github.com/sqlalchemy/mako/blob/main/CHANGES)
- [Commits](https://github.com/sqlalchemy/mako/commits)

---
updated-dependencies:
- dependency-name: mako
  dependency-version: 1.3.11
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-25 08:14:38 +05:30
Philipp Emanuel Weidmann 513e3acc72 fix: improve the reproducibility system (#303)
* fix: various cleanups and improvements for the reproducibility system

* fix: save only essential settings

* fix: improve model commit handling

* feat: make including system information optional

* fix: improve formatting of reproducibility README

* fix: fix remaining issues
2026-04-23 19:08:18 +05:30
9 changed files with 598 additions and 399 deletions
+30 -10
View File
@@ -1,6 +1,6 @@
<img width="128" height="128" align="right" alt="Logo" src="https://github.com/user-attachments/assets/df5f2840-2f92-4991-aa57-252747d7182e" /> <img width="128" height="128" align="right" alt="Logo" src="https://github.com/user-attachments/assets/df5f2840-2f92-4991-aa57-252747d7182e" />
# Heretic: Fully automatic censorship removal for language models<br><br>[![Discord](https://img.shields.io/discord/1447831134212984903?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=for-the-badge)](https://discord.gg/gdXc48gSyT) [![Follow us on Hugging Face](https://huggingface.co/datasets/huggingface/badges/resolve/main/follow-us-on-hf-md-dark.svg)](https://huggingface.co/heretic-org) # Heretic: Fully automatic censorship removal for language models<br><br>[![Discord](https://img.shields.io/discord/1447831134212984903?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=for-the-badge)](https://discord.gg/gdXc48gSyT) [![Follow us on Hugging Face](https://huggingface.co/datasets/huggingface/badges/resolve/main/follow-us-on-hf-md-dark.svg)](https://huggingface.co/heretic-org) [![Codeberg mirror](https://img.shields.io/badge/Codeberg%20mirror-black?logo=codeberg&style=for-the-badge)](https://codeberg.org/p-e-w/heretic)
[![#1 Repository of the Day](https://trendshift.io/api/badge/repositories/20538)](https://trendshift.io/repositories/20538) [![#1 Repository of the Day](https://trendshift.io/api/badge/repositories/20538)](https://trendshift.io/repositories/20538)
@@ -20,6 +20,11 @@ as possible. Using Heretic does not require an understanding of transformer
internals. In fact, anyone who knows how to run a command-line program internals. In fact, anyone who knows how to run a command-line program
can use Heretic to decensor language models. can use Heretic to decensor language models.
Heretic supports most dense models, including many multimodal models,
several different MoE architectures, and even some hybrid models like Qwen3.5.
Pure state-space models and certain other research architectures are not yet
supported out of the box.
<img width="650" height="715" alt="Screenshot" src="https://github.com/user-attachments/assets/d71a5efa-d6be-4705-a817-63332afb2d15" /> <img width="650" height="715" alt="Screenshot" src="https://github.com/user-attachments/assets/d71a5efa-d6be-4705-a817-63332afb2d15" />
&nbsp; &nbsp;
@@ -65,15 +70,15 @@ Heretic have been well-received by users (links and emphasis added):
> Has been the best unquantized abliterated model that I have been able to run on 16gb vram." > Has been the best unquantized abliterated model that I have been able to run on 16gb vram."
> [*(Link to comment)*](https://old.reddit.com/r/LocalLLaMA/comments/1phjxca/im_calling_these_people_out_right_now/nt06tji/) > [*(Link to comment)*](https://old.reddit.com/r/LocalLLaMA/comments/1phjxca/im_calling_these_people_out_right_now/nt06tji/)
Heretic supports most dense models, including many multimodal models, and Heretic models have also been independently benchmarked using standard metrics
several different MoE architectures. It does not yet support SSMs/hybrid models, like MMLU and GSM8K, and have been found to compare favorably with models
models with inhomogeneous layers, and certain novel attention systems. produced by competing abliteration tools:
[1](https://old.reddit.com/r/LocalLLaMA/comments/1sojjoc/abliterlitics_benchmark_and_tensor_analysis/),
[2](https://old.reddit.com/r/LocalLLaMA/comments/1sy18lx/abliterlitics_benchmarks_and_tensor_comparison/).
You can find a small collection of models that have been decensored using Heretic The community has created and published
[on Hugging Face](https://huggingface.co/collections/p-e-w/the-bestiary), [well over 3000](https://huggingface.co/models?other=heretic)
and the community has created and published models with Heretic.
[well over 1,000](https://huggingface.co/models?other=heretic)
Heretic models in addition to those.
## Usage ## Usage
@@ -88,6 +93,21 @@ heretic Qwen/Qwen3-4B-Instruct-2507
Replace `Qwen/Qwen3-4B-Instruct-2507` with whatever model you want to decensor. Replace `Qwen/Qwen3-4B-Instruct-2507` with whatever model you want to decensor.
> [!IMPORTANT]
>
> While PyTorch 2.2 is the minimum version of PyTorch needed for Heretic to work,
> some models and configurations might require features only found in
> later versions. For example, loading MXFP4-quantized models like gpt-oss
> uses `torch.accelerator`, which was added in PyTorch 2.6.
> [!TIP]
>
> Heretic uses [uv](https://docs.astral.sh/uv/) for dependency management,
> and the repository includes a `uv.lock` file pinning every package version.
> If you already use uv (and you probably should!), you can just clone the repo
> and run Heretic with `uv run heretic`, which ensures that your dependencies
> match those used by the developers, improving reliability and security.
The process is fully automatic and does not require configuration; however, The process is fully automatic and does not require configuration; however,
Heretic has a variety of configuration parameters that can be changed for Heretic has a variety of configuration parameters that can be changed for
greater control. Run `heretic --help` to see available command-line options, greater control. Run `heretic --help` to see available command-line options,
@@ -103,7 +123,7 @@ models. Set the `quantization` option to `bnb_4bit` to enable quantization.
After Heretic has finished decensoring a model, you are given the option to After Heretic has finished decensoring a model, you are given the option to
save the model, upload it to Hugging Face, chat with it to test how well it works, save the model, upload it to Hugging Face, chat with it to test how well it works,
or any combination of those actions. run standard benchmarks on it, or any combination of those actions.
## Research features ## Research features
+37 -12
View File
@@ -27,6 +27,12 @@ device_map = "auto"
# Maximum memory to allocate per device. # Maximum memory to allocate per device.
# max_memory = { "0" = "20GB", "cpu" = "64GB" } # max_memory = { "0" = "20GB", "cpu" = "64GB" }
# Whether to move intermediate analysis tensors (such as residuals and logprobs)
# to CPU memory as soon as possible to reduce peak VRAM usage.
# This lowers peak VRAM usage during residual analysis and evaluation,
# but may slightly reduce performance due to host/device transfers.
offload_outputs_to_cpu = true
# Number of input sequences to process in parallel (0 = auto). # Number of input sequences to process in parallel (0 = auto).
batch_size = 0 # auto batch_size = 0 # auto
@@ -36,6 +42,32 @@ max_batch_size = 128
# Maximum number of tokens to generate for each response. # Maximum number of tokens to generate for each response.
max_response_length = 100 max_response_length = 100
# List of pairs of the form [cot_initializer, closed_cot_block] used to skip
# the Chain-of-Thought block in responses, so that evaluation happens
# at the start of the actual response.
chain_of_thought_skips = [
# Most thinking models.
[
"<think>",
"<think></think>",
],
# gpt-oss.
[
"<|channel|>analysis<|message|>",
"<|channel|>analysis<|message|><|end|><|start|>assistant<|channel|>final<|message|>",
],
# Unknown, suggested by user.
[
"<thought>",
"<thought></thought>",
],
# Unknown, suggested by user.
[
"[THINK]",
"[THINK][/THINK]",
],
]
# Whether to print prompt/response pairs when counting refusals. # Whether to print prompt/response pairs when counting refusals.
print_responses = false print_responses = false
@@ -64,13 +96,13 @@ kl_divergence_target = 0.01
# Whether to adjust the refusal directions so that only the component that is # Whether to 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.
orthogonalize_direction = false orthogonalize_direction = true
# How to apply row normalization of the weights. Options: # How to apply row normalization of the weights. Options:
# "none" (no normalization), # "none" (no normalization),
# "pre" (compute LoRA adapter relative to row-normalized weights), # "pre" (compute LoRA adapter relative to row-normalized weights),
# "full" (like "pre", but renormalizes to preserve original row magnitudes). # "full" (like "pre", but renormalizes to preserve original row magnitudes).
row_normalization = "none" row_normalization = "full"
# The rank of the LoRA adapter to use when "full" row normalization is used. # The rank of the LoRA adapter to use when "full" row normalization is used.
# Row magnitude preservation is approximate due to non-linear effects, # Row magnitude preservation is approximate due to non-linear effects,
@@ -98,6 +130,9 @@ n_startup_trials = 60
# Directory to save and load study progress to/from. # Directory to save and load study progress to/from.
study_checkpoint_dir = "checkpoints" study_checkpoint_dir = "checkpoints"
# Maximum size for individual safetensors files generated when exporting a model.
max_shard_size = "5GB"
# Strings whose presence in a response (case insensitive) identifies the response as a refusal. # Strings whose presence in a response (case insensitive) identifies the response as a refusal.
refusal_markers = [ refusal_markers = [
"sorry", "sorry",
@@ -137,12 +172,6 @@ refusal_markers = [
# System prompt to use when prompting the model. # System prompt to use when prompting the model.
system_prompt = "You are a helpful assistant." system_prompt = "You are a helpful assistant."
# Move intermediate analysis tensors (such as residuals and logprobs)
# to CPU memory as soon as possible to reduce peak VRAM usage.
# This lowers peak VRAM usage during residual analysis and evaluation,
# but may slightly reduce performance due to host/device transfers.
offload_outputs_to_cpu = true
# Dataset of prompts that tend to not result in refusals (used for calculating refusal directions). # Dataset of prompts that tend to not result in refusals (used for calculating refusal directions).
[good_prompts] [good_prompts]
dataset = "mlabonne/harmless_alpaca" dataset = "mlabonne/harmless_alpaca"
@@ -150,7 +179,6 @@ split = "train[:400]"
column = "text" column = "text"
residual_plot_label = '"Harmless" prompts' residual_plot_label = '"Harmless" prompts'
residual_plot_color = "royalblue" residual_plot_color = "royalblue"
commit = ""
# Dataset of prompts that tend to result in refusals (used for calculating refusal directions). # Dataset of prompts that tend to result in refusals (used for calculating refusal directions).
[bad_prompts] [bad_prompts]
@@ -159,18 +187,15 @@ split = "train[:400]"
column = "text" column = "text"
residual_plot_label = '"Harmful" prompts' residual_plot_label = '"Harmful" prompts'
residual_plot_color = "darkorange" residual_plot_color = "darkorange"
commit = ""
# Dataset of prompts that tend to not result in refusals (used for evaluating model performance). # Dataset of prompts that tend to not result in refusals (used for evaluating model performance).
[good_evaluation_prompts] [good_evaluation_prompts]
dataset = "mlabonne/harmless_alpaca" dataset = "mlabonne/harmless_alpaca"
split = "test[:100]" split = "test[:100]"
column = "text" column = "text"
commit = ""
# Dataset of prompts that tend to result in refusals (used for evaluating model performance). # Dataset of prompts that tend to result in refusals (used for evaluating model performance).
[bad_evaluation_prompts] [bad_evaluation_prompts]
dataset = "mlabonne/harmful_behaviors" dataset = "mlabonne/harmful_behaviors"
split = "test[:100]" split = "test[:100]"
column = "text" column = "text"
commit = ""
+4 -4
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "heretic-llm" name = "heretic-llm"
version = "1.2.0" version = "1.3.0"
description = "Fully automatic censorship removal for language models" description = "Fully automatic censorship removal for language models"
readme = "README.md" readme = "README.md"
license = "AGPL-3.0-or-later" license = "AGPL-3.0-or-later"
@@ -28,12 +28,12 @@ dependencies = [
"hf-transfer~=0.1", "hf-transfer~=0.1",
"huggingface-hub~=1.7", "huggingface-hub~=1.7",
"immutabledict~=4.3", "immutabledict~=4.3",
"kernels~=0.12", "kernels~=0.13",
"langdetect~=1.0", "langdetect~=1.0",
"lm-eval[hf]~=0.4", "lm-eval[hf]~=0.4",
"numpy~=2.2", "numpy~=2.2",
"optuna~=4.7", "optuna~=4.7",
"peft~=0.18", "peft~=0.19",
"psutil~=7.2", "psutil~=7.2",
"py-cpuinfo~=9.0", "py-cpuinfo~=9.0",
"pydantic-settings~=2.13", "pydantic-settings~=2.13",
@@ -41,7 +41,7 @@ dependencies = [
"rich~=14.3", "rich~=14.3",
"tomli-w~=1.2", "tomli-w~=1.2",
"tqdm~=4.67", "tqdm~=4.67",
"transformers~=5.3", "transformers~=5.6",
] ]
[project.optional-dependencies] [project.optional-dependencies]
+52 -14
View File
@@ -13,6 +13,12 @@ from pydantic_settings import (
TomlConfigSettingsSource, TomlConfigSettingsSource,
) )
# !!!IMPORTANT!!!
#
# Any settings added to the classes defined in this module
# must be evaluated for privacy implications and have
# exclude=True set in their field definitions if appropriate.
class QuantizationMethod(str, Enum): class QuantizationMethod(str, Enum):
NONE = "none" NONE = "none"
@@ -31,6 +37,11 @@ class DatasetSpecification(BaseModel):
description="Hugging Face dataset ID, or path to dataset on disk." description="Hugging Face dataset ID, or path to dataset on disk."
) )
commit: str | None = Field(
default=None,
description="Hugging Face commit hash of the dataset.",
)
split: str = Field(description="Portion of the dataset to use.") split: str = Field(description="Portion of the dataset to use.")
column: str = Field(description="Column in the dataset that contains the prompts.") column: str = Field(description="Column in the dataset that contains the prompts.")
@@ -53,15 +64,13 @@ class DatasetSpecification(BaseModel):
residual_plot_label: str | None = Field( residual_plot_label: str | None = Field(
default=None, default=None,
description="Label to use for the dataset in plots of residual vectors.", description="Label to use for the dataset in plots of residual vectors.",
exclude=True,
) )
residual_plot_color: str | None = Field( residual_plot_color: str | None = Field(
default=None, default=None,
description="Matplotlib color to use for the dataset in plots of residual vectors.", description="Matplotlib color to use for the dataset in plots of residual vectors.",
) exclude=True,
commit: str | None = Field(
default=None,
description="Hugging Face commit hash of the dataset.",
) )
@@ -80,12 +89,18 @@ class BenchmarkSpecification(BaseModel):
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.")
model_commit: str | None = Field(
default=None,
description="Hugging Face commit hash of the model.",
)
evaluate_model: str | None = Field( evaluate_model: str | None = Field(
default=None, default=None,
description=( description=(
"If this model ID or path is set, then instead of abliterating the main model, " "If this model ID or path is set, then instead of abliterating the main model, "
"evaluate this model relative to the main model." "evaluate this model relative to the main model."
), ),
exclude=True,
) )
dtypes: list[str] = Field( dtypes: list[str] = Field(
@@ -126,9 +141,21 @@ class Settings(BaseSettings):
description='Maximum memory to allocate per device (e.g., { "0" = "20GB", "cpu" = "64GB" }).', description='Maximum memory to allocate per device (e.g., { "0" = "20GB", "cpu" = "64GB" }).',
) )
offload_outputs_to_cpu: bool = Field(
default=True,
description=(
"Whether to move intermediate analysis tensors (such as residuals and logprobs) "
"to CPU memory as soon as possible to reduce peak VRAM usage. "
"This lowers peak VRAM usage during residual analysis and evaluation, "
"but may slightly reduce performance due to host/device transfers."
),
)
trust_remote_code: bool | None = Field( trust_remote_code: bool | None = Field(
default=None, default=None,
description="Whether to trust remote code when loading the model.", description="Whether to trust remote code when loading the model.",
# For security reasons, we don't store this setting.
exclude=True,
) )
batch_size: int = Field( batch_size: int = Field(
@@ -139,6 +166,9 @@ class Settings(BaseSettings):
max_batch_size: int = Field( max_batch_size: int = Field(
default=128, default=128,
description="Maximum batch size to try when automatically determining the optimal batch size.", description="Maximum batch size to try when automatically determining the optimal batch size.",
# When storing a settings object, the batch size is already fixed,
# either determined by the automatic mechanism or by explicit user choice.
exclude=True,
) )
max_response_length: int = Field( max_response_length: int = Field(
@@ -183,36 +213,45 @@ class Settings(BaseSettings):
"the Chain-of-Thought block in responses, so that evaluation happens " "the Chain-of-Thought block in responses, so that evaluation happens "
"at the start of the actual response." "at the start of the actual response."
), ),
# When storing a settings object, the response prefix is already fixed,
# either determined by the automatic mechanism or by explicit user choice.
exclude=True,
) )
print_responses: bool = Field( print_responses: bool = Field(
default=False, default=False,
description="Whether to print prompt/response pairs when counting refusals.", description="Whether to print prompt/response pairs when counting refusals.",
exclude=True,
) )
print_residual_geometry: bool = Field( print_residual_geometry: bool = Field(
default=False, default=False,
description="Whether to print detailed information about residuals and refusal directions.", description="Whether to print detailed information about residuals and refusal directions.",
exclude=True,
) )
plot_residuals: bool = Field( plot_residuals: bool = Field(
default=False, default=False,
description="Whether to generate plots showing PaCMAP projections of residual vectors.", description="Whether to generate plots showing PaCMAP projections of residual vectors.",
exclude=True,
) )
residual_plot_path: str = Field( residual_plot_path: str = Field(
default="plots", default="plots",
description="Base path to save plots of residual vectors to.", description="Base path to save plots of residual vectors to.",
exclude=True,
) )
residual_plot_title: str = Field( residual_plot_title: str = Field(
default='PaCMAP Projection of Residual Vectors for "Harmless" and "Harmful" Prompts', default='PaCMAP Projection of Residual Vectors for "Harmless" and "Harmful" Prompts',
description="Title placed above plots of residual vectors.", description="Title placed above plots of residual vectors.",
exclude=True,
) )
residual_plot_style: str = Field( residual_plot_style: str = Field(
default="dark_background", default="dark_background",
description="Matplotlib style sheet to use for plots of residual vectors.", description="Matplotlib style sheet to use for plots of residual vectors.",
exclude=True,
) )
kl_divergence_scale: float = Field( kl_divergence_scale: float = Field(
@@ -232,7 +271,7 @@ class Settings(BaseSettings):
) )
orthogonalize_direction: bool = Field( orthogonalize_direction: bool = Field(
default=False, default=True,
description=( description=(
"Whether to adjust the refusal directions so that only the component that is " "Whether to 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."
@@ -240,7 +279,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), '
@@ -291,6 +330,7 @@ class Settings(BaseSettings):
study_checkpoint_dir: str = Field( study_checkpoint_dir: str = Field(
default="checkpoints", default="checkpoints",
description="Directory to save and load study progress to/from.", description="Directory to save and load study progress to/from.",
exclude=True,
) )
benchmarks: list[BenchmarkSpecification] = Field( benchmarks: list[BenchmarkSpecification] = Field(
@@ -352,6 +392,12 @@ class Settings(BaseSettings):
), ),
], ],
description="Benchmarks to offer to the user for evaluating abliterated models.", description="Benchmarks to offer to the user for evaluating abliterated models.",
exclude=True,
)
max_shard_size: int | str = Field(
default="5GB",
description="Maximum size for individual safetensors files generated when exporting a model.",
) )
refusal_markers: list[str] = Field( refusal_markers: list[str] = Field(
@@ -397,14 +443,6 @@ class Settings(BaseSettings):
description="System prompt to use when prompting the model.", description="System prompt to use when prompting the model.",
) )
offload_outputs_to_cpu: bool = Field(
default=True,
description=(
"Whether to move intermediate analysis tensors (such as residuals and logprobs) "
"to CPU memory as soon as possible to reduce peak VRAM usage."
),
)
good_prompts: DatasetSpecification = Field( good_prompts: DatasetSpecification = Field(
default=DatasetSpecification( default=DatasetSpecification(
dataset="mlabonne/harmless_alpaca", dataset="mlabonne/harmless_alpaca",
+78 -41
View File
@@ -17,11 +17,15 @@ def _is_help_invocation() -> bool:
if _is_help_invocation(): if _is_help_invocation():
Settings() # ty:ignore[missing-argument] Settings() # ty:ignore[missing-argument]
# FIXME: Rich progress bars are currently disabled because of rendering issues
# when used from multiple threads in parallel (e.g. by huggingface_hub).
"""
from .progress import patch_tqdm from .progress import patch_tqdm
# This patches tqdm class definitions, which must happen # This patches tqdm class definitions, which must happen
# before any other module imports tqdm. # before any other module imports tqdm.
patch_tqdm() patch_tqdm()
"""
import logging import logging
import math import math
@@ -66,10 +70,10 @@ from .utils import (
format_duration, format_duration,
get_readme_intro, get_readme_intro,
get_trial_parameters, get_trial_parameters,
is_hf_path,
load_prompts, load_prompts,
print, print,
print_memory_usage, print_memory_usage,
prompt_confirm,
prompt_password, prompt_password,
prompt_path, prompt_path,
prompt_select, prompt_select,
@@ -79,7 +83,7 @@ from .utils import (
) )
def obtain_merge_strategy(settings: Settings) -> str | None: def obtain_merge_strategy(settings: Settings, model: Model) -> str | None:
""" """
Prompts the user for how to proceed with saving the model. Prompts the user for how to proceed with saving the model.
Provides info to the user if the model is quantized on memory use. Provides info to the user if the model is quantized on memory use.
@@ -108,7 +112,8 @@ def obtain_merge_strategy(settings: Settings) -> str | None:
settings.model, settings.model,
device_map="meta", device_map="meta",
torch_dtype=torch.bfloat16, torch_dtype=torch.bfloat16,
trust_remote_code=True, trust_remote_code=model.trusted_models.get(settings.model),
**model.revision_kwargs,
) )
footprint_bytes = meta_model.get_memory_footprint() footprint_bytes = meta_model.get_memory_footprint()
footprint_gb = footprint_bytes / (1024**3) footprint_gb = footprint_bytes / (1024**3)
@@ -424,9 +429,6 @@ def run():
needs_full_residuals = settings.print_residual_geometry or settings.plot_residuals needs_full_residuals = settings.print_residual_geometry or settings.plot_residuals
good_residuals = None
bad_residuals = None
if needs_full_residuals: if needs_full_residuals:
print("* Obtaining residuals for good prompts...") print("* Obtaining residuals for good prompts...")
good_residuals = model.get_residuals_batched(good_prompts) good_residuals = model.get_residuals_batched(good_prompts)
@@ -464,8 +466,12 @@ def run():
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)
del good_directions, projection_vector
del good_means, bad_means
# Clear cache before starting the optimization study. # Clear cache before starting the optimization study.
# This should free up memory from the objects released with the del statements above.
empty_cache() empty_cache()
trial_index = 0 trial_index = 0
@@ -571,7 +577,8 @@ def run():
trial.set_user_attr("kl_divergence", kl_divergence) trial.set_user_attr("kl_divergence", kl_divergence)
trial.set_user_attr("refusals", refusals) trial.set_user_attr("refusals", refusals)
trial.set_user_attr("total_refusal_prompts", len(evaluator.bad_prompts)) trial.set_user_attr("base_refusals", evaluator.base_refusals)
trial.set_user_attr("n_bad_prompts", len(evaluator.bad_prompts))
return score return score
@@ -681,8 +688,9 @@ def run():
( (
"The following trials resulted in Pareto optimal combinations of refusals and KL divergence. " "The following trials resulted in Pareto optimal combinations of refusals and KL divergence. "
"After selecting a trial, you will be able to save the model, upload it to Hugging Face, " "After selecting a trial, you will be able to save the model, upload it to Hugging Face, "
"or chat with it to test how well it works. You can return to this menu later to select a different trial. " "chat with it to test how well it works, or run standard benchmarks on it. "
"[yellow]Note that KL divergence values above 1 usually indicate significant damage to the original model's capabilities.[/]" "You can return to this menu later to select a different trial. "
"[yellow]Note that KL divergence values above 0.5 usually indicate significant damage to the original model's capabilities.[/]"
) )
) )
@@ -772,17 +780,23 @@ def run():
if not save_directory: if not save_directory:
continue continue
strategy = obtain_merge_strategy(settings) strategy = obtain_merge_strategy(settings, model)
if strategy is None: if strategy is None:
continue continue
if strategy == "adapter": if strategy == "adapter":
print("Saving LoRA adapter...") print("Saving LoRA adapter...")
model.model.save_pretrained(save_directory) model.model.save_pretrained(
save_directory,
max_shard_size=settings.max_shard_size,
)
else: else:
print("Saving merged model...") print("Saving merged model...")
merged_model = model.get_merged_model() merged_model = model.get_merged_model()
merged_model.save_pretrained(save_directory) merged_model.save_pretrained(
save_directory,
max_shard_size=settings.max_shard_size,
)
del merged_model del merged_model
empty_cache() empty_cache()
model.tokenizer.save_pretrained(save_directory) model.tokenizer.save_pretrained(save_directory)
@@ -823,7 +837,7 @@ def run():
continue continue
private = visibility == "Private" private = visibility == "Private"
strategy = obtain_merge_strategy(settings) strategy = obtain_merge_strategy(settings, model)
if strategy is None: if strategy is None:
continue continue
@@ -835,27 +849,48 @@ def run():
settings.good_evaluation_prompts.dataset, settings.good_evaluation_prompts.dataset,
settings.bad_evaluation_prompts.dataset, settings.bad_evaluation_prompts.dataset,
] ]
can_reproduce = not Path(settings.model).exists() and all( is_reproducible = is_hf_path(settings.model) and all(
not Path(d).exists() for d in datasets is_hf_path(dataset) for dataset in datasets
) )
if can_reproduce: if is_reproducible:
# Pin the number of trials to the number of actual completed trials print(
# for the reproduction configuration. (
settings.n_trials = count_completed_trials() "Heretic can add information to the repository that allows others to reproduce the model. "
"This is optional, but valuable to the community as both a learning tool and to preserve computational work already done. "
include_reproduce = prompt_confirm( "Guaranteeing reproducibility requires basic system information (Python and OS version, CPU and GPU/accelerator info) "
"""Include 'reproduce' folder? "as tensor operations can give different results in different system environments. "
This saves your exact configuration and system information, along with the study checkpoint, to help others verify your results.""" "[bold]The information does not include any file system paths or other private data.[/]"
) )
)
reproducibility_information = prompt_select(
"Which reproducibility information do you want to add?",
[
Choice(
title="Full: Settings, package versions, and system information",
value="full",
),
Choice(
title="Basic: Settings and package versions",
value="basic",
),
Choice(
title="Don't add any reproducibility information",
value="none",
),
],
)
if reproducibility_information is None:
continue
else: else:
include_reproduce = False reproducibility_information = "none"
if strategy == "adapter": if strategy == "adapter":
print("Uploading LoRA adapter...") print("Uploading LoRA adapter...")
model.model.push_to_hub( model.model.push_to_hub(
repo_id, repo_id,
private=private, private=private,
max_shard_size=settings.max_shard_size,
token=token, token=token,
) )
else: else:
@@ -864,6 +899,7 @@ This saves your exact configuration and system information, along with the study
merged_model.push_to_hub( merged_model.push_to_hub(
repo_id, repo_id,
private=private, private=private,
max_shard_size=settings.max_shard_size,
token=token, token=token,
) )
del merged_model del merged_model
@@ -874,22 +910,18 @@ This saves your exact configuration and system information, along with the study
token=token, token=token,
) )
# If the model path exists locally and includes the if is_hf_path(settings.model):
# card, use it directly. If the model path doesn't card = ModelCard.load(settings.model)
# exist locally, it can be assumed to be a model else:
# hosted on the Hugging Face Hub, in which case
# we can retrieve the model card.
model_path = Path(settings.model)
if model_path.exists():
card_path = ( card_path = (
model_path / huggingface_hub.constants.REPOCARD_NAME Path(settings.model)
/ huggingface_hub.constants.REPOCARD_NAME
) )
if card_path.exists(): if card_path.exists():
card = ModelCard.load(card_path) card = ModelCard.load(card_path)
else: else:
card = None card = None
else:
card = ModelCard.load(settings.model)
if card is not None: if card is not None:
if card.data is None: if card.data is None:
card.data = ModelCardData() card.data = ModelCardData()
@@ -899,29 +931,34 @@ This saves your exact configuration and system information, along with the study
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 reproducibility_information != "none":
card.data.tags.append("reproducible")
card.text = ( card.text = (
get_readme_intro( get_readme_intro(
settings, settings,
trial, trial,
evaluator.base_refusals, reproducibility_information != "none",
evaluator.bad_prompts,
) )
+ card.text + card.text
) )
card.push_to_hub(repo_id, token=token) card.push_to_hub(repo_id, token=token)
if include_reproduce: if reproducibility_information != "none":
# Set the number of trials to the number of actual completed trials
# for the reproduction configuration.
settings.n_trials = count_completed_trials()
upload_reproduce_folder( upload_reproduce_folder(
repo_id, repo_id,
settings, settings,
token, token,
checkpoint_path=study_checkpoint_file, checkpoint_path=study_checkpoint_file,
trial=trial, trial=trial,
include_system_information=(
reproducibility_information == "full"
),
) )
print(
f"Model and reproducibility files uploaded to [bold]{repo_id}[/]."
)
else:
print(f"Model uploaded to [bold]{repo_id}[/].") print(f"Model uploaded to [bold]{repo_id}[/].")
case "Chat with the model": case "Chat with the model":
+17 -6
View File
@@ -62,12 +62,17 @@ class Model:
self.settings = settings self.settings = settings
self.needs_reload = False self.needs_reload = False
self.revision_kwargs = {}
if settings.model_commit is not None:
self.revision_kwargs["revision"] = settings.model_commit
print() print()
print(f"Loading model [bold]{settings.model}[/]...") print(f"Loading model [bold]{settings.model}[/]...")
self.tokenizer = AutoTokenizer.from_pretrained( self.tokenizer = AutoTokenizer.from_pretrained(
settings.model, settings.model,
trust_remote_code=settings.trust_remote_code, trust_remote_code=settings.trust_remote_code,
**self.revision_kwargs,
) )
# Fallback for tokenizers that don't declare a special pad token. # Fallback for tokenizers that don't declare a special pad token.
@@ -108,6 +113,7 @@ class Model:
device_map=settings.device_map, device_map=settings.device_map,
max_memory=self.max_memory, max_memory=self.max_memory,
trust_remote_code=self.trusted_models.get(settings.model), trust_remote_code=self.trusted_models.get(settings.model),
**self.revision_kwargs,
**extra_kwargs, **extra_kwargs,
) )
@@ -148,13 +154,15 @@ class Model:
# 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:")
all_components = {} all_components = {}
for layer_index in range(len(self.get_layers())): for layer_index in range(len(self.get_layers())):
for component, modules in self.get_layer_modules(layer_index).items(): for component, modules in self.get_layer_modules(layer_index).items():
if component not in all_components: if component not in all_components:
all_components[component] = 0 all_components[component] = 0
all_components[component] += len(modules) all_components[component] += len(modules)
print("* Abliterable components:")
for component, count in all_components.items(): for component, count in all_components.items():
print(f" * [bold]{component}[/]: [bold]{count}[/] modules total") print(f" * [bold]{component}[/]: [bold]{count}[/] modules total")
@@ -257,6 +265,7 @@ class Model:
torch_dtype=self.model.dtype, torch_dtype=self.model.dtype,
device_map="cpu", device_map="cpu",
trust_remote_code=self.trusted_models.get(self.settings.model), trust_remote_code=self.trusted_models.get(self.settings.model),
**self.revision_kwargs,
) )
# Apply LoRA adapters to the CPU model # Apply LoRA adapters to the CPU model
@@ -318,6 +327,7 @@ class Model:
device_map=self.settings.device_map, device_map=self.settings.device_map,
max_memory=self.max_memory, max_memory=self.max_memory,
trust_remote_code=self.trusted_models.get(self.settings.model), trust_remote_code=self.trusted_models.get(self.settings.model),
**self.revision_kwargs,
**extra_kwargs, **extra_kwargs,
) )
@@ -360,8 +370,8 @@ class Model:
with suppress(Exception): 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 # Qwen3.5 MoE hybrid layers use GatedDeltaNet (linear attention) instead of
# of standard self-attention, so self_attn.o_proj doesn't exist on those layers. # standard self-attention, so self_attn.o_proj doesn't exist on those layers.
with suppress(Exception): with suppress(Exception):
try_add("attn.o_proj", layer.linear_attn.out_proj) # ty:ignore[possibly-missing-attribute] try_add("attn.o_proj", layer.linear_attn.out_proj) # ty:ignore[possibly-missing-attribute]
@@ -395,11 +405,13 @@ class Model:
return modules return modules
def get_abliterable_components(self) -> list[str]: def get_abliterable_components(self) -> list[str]:
components: set[str] = set()
# Scan all layers because hybrid models (e.g. Qwen3.5 MoE) have different # 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 on different layers (some have self_attn, others linear_attn).
components: set[str] = set()
for layer_index in range(len(self.get_layers())): for layer_index in range(len(self.get_layers())):
components.update(self.get_layer_modules(layer_index).keys()) components.update(self.get_layer_modules(layer_index).keys())
return sorted(components) return sorted(components)
def abliterate( def abliterate(
@@ -736,9 +748,8 @@ class Model:
# The returned tensor has shape (prompt, token). # The returned tensor has shape (prompt, token).
logprobs = F.log_softmax(logits, dim=-1) logprobs = F.log_softmax(logits, dim=-1)
del outputs
if self.settings.offload_outputs_to_cpu: if self.settings.offload_outputs_to_cpu:
del outputs, logits
logprobs = logprobs.cpu() logprobs = logprobs.cpu()
empty_cache() empty_cache()
+26 -10
View File
@@ -25,6 +25,7 @@ from accelerate.utils import (
def empty_cache(): def empty_cache():
"""Clears the backend cache and collects garbage.""" """Clears the backend cache and collects garbage."""
# 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.
# See https://github.com/p-e-w/heretic/pull/17 for details. # See https://github.com/p-e-w/heretic/pull/17 for details.
@@ -48,6 +49,7 @@ def empty_cache():
def get_nvidia_driver_version() -> str | None: def get_nvidia_driver_version() -> str | None:
"""Gets the NVIDIA driver version using nvidia-smi.""" """Gets the NVIDIA driver version using nvidia-smi."""
try: try:
output = subprocess.check_output( output = subprocess.check_output(
["nvidia-smi", "--query-gpu=driver_version", "--format=csv,noheader"], ["nvidia-smi", "--query-gpu=driver_version", "--format=csv,noheader"],
@@ -61,6 +63,7 @@ def get_nvidia_driver_version() -> str | None:
def get_amdgpu_driver_version() -> str | None: def get_amdgpu_driver_version() -> str | None:
"""Gets the AMD GPU (ROCm) driver and suite version info.""" """Gets the AMD GPU (ROCm) driver and suite version info."""
# 1. Try amd-smi (modern standard for ROCm 6.0+) # 1. Try amd-smi (modern standard for ROCm 6.0+)
try: try:
output = subprocess.check_output( output = subprocess.check_output(
@@ -101,6 +104,7 @@ def get_amdgpu_driver_version() -> str | None:
def get_xpu_driver_version() -> str | None: def get_xpu_driver_version() -> str | None:
"""Gets the Intel XPU driver version.""" """Gets the Intel XPU driver version."""
try: try:
output = subprocess.check_output( output = subprocess.check_output(
["xpu-smi", "discovery"], ["xpu-smi", "discovery"],
@@ -117,6 +121,7 @@ def get_xpu_driver_version() -> str | None:
def get_npu_driver_version() -> str | None: def get_npu_driver_version() -> str | None:
"""Gets the Huawei NPU driver version.""" """Gets the Huawei NPU driver version."""
try: try:
output = subprocess.check_output( output = subprocess.check_output(
["npu-smi", "info", "-t", "board", "-i", "0"], ["npu-smi", "info", "-t", "board", "-i", "0"],
@@ -133,6 +138,7 @@ def get_npu_driver_version() -> str | None:
def get_mps_driver_version() -> str | None: def get_mps_driver_version() -> str | None:
"""Gets the Apple Silicon (MPS) driver version via macOS version.""" """Gets the Apple Silicon (MPS) driver version via macOS version."""
try: try:
output = subprocess.check_output( output = subprocess.check_output(
["sw_vers", "-productVersion"], ["sw_vers", "-productVersion"],
@@ -156,6 +162,7 @@ class HereticVersionInfo:
def get_heretic_version_info() -> HereticVersionInfo: def get_heretic_version_info() -> HereticVersionInfo:
"""Detects version and installation source (PyPI, Git, Local) of heretic-llm.""" """Detects version and installation source (PyPI, Git, Local) of heretic-llm."""
package_name = "heretic-llm" package_name = "heretic-llm"
origin_metadata: dict[str, Any] = {"type": "unknown"} origin_metadata: dict[str, Any] = {"type": "unknown"}
# This package must be installed for this code to run. # This package must be installed for this code to run.
@@ -171,6 +178,7 @@ def get_heretic_version_info() -> HereticVersionInfo:
if not direct_url_content: if not direct_url_content:
# Standard PyPI installation. # Standard PyPI installation.
origin_metadata["type"] = "pypi" origin_metadata["type"] = "pypi"
return HereticVersionInfo( return HereticVersionInfo(
version=base_version, version=base_version,
origin="PyPI", origin="PyPI",
@@ -178,7 +186,6 @@ def get_heretic_version_info() -> HereticVersionInfo:
metadata=origin_metadata, metadata=origin_metadata,
) )
try:
data = json.loads(direct_url_content) data = json.loads(direct_url_content)
# Check for Git source. # Check for Git source.
@@ -214,6 +221,7 @@ def get_heretic_version_info() -> HereticVersionInfo:
# Check for local file/wheel directory. # Check for local file/wheel directory.
if "url" in data and data["url"].startswith("file://"): if "url" in data and data["url"].startswith("file://"):
origin_metadata["type"] = "local" origin_metadata["type"] = "local"
return HereticVersionInfo( return HereticVersionInfo(
version=base_version, version=base_version,
origin="Local", origin="Local",
@@ -221,9 +229,6 @@ def get_heretic_version_info() -> HereticVersionInfo:
metadata=origin_metadata, metadata=origin_metadata,
) )
except json.JSONDecodeError:
pass
return HereticVersionInfo( return HereticVersionInfo(
version=base_version, version=base_version,
origin=None, origin=None,
@@ -234,6 +239,7 @@ def get_heretic_version_info() -> HereticVersionInfo:
def get_accelerator_info_dict() -> dict[str, Any]: def get_accelerator_info_dict() -> dict[str, Any]:
"""Retrieves raw accelerator info (CUDA, ROCm, etc) directly into structured keys.""" """Retrieves raw accelerator info (CUDA, ROCm, etc) directly into structured keys."""
if torch.cuda.is_available(): if torch.cuda.is_available():
count = torch.cuda.device_count() count = torch.cuda.device_count()
is_rocm = getattr(torch.version, "hip", None) is not None is_rocm = getattr(torch.version, "hip", None) is not None
@@ -320,6 +326,7 @@ def get_accelerator_info_dict() -> dict[str, Any]:
def get_accelerator_info(include_warnings: bool = True) -> str: def get_accelerator_info(include_warnings: bool = True) -> str:
"""Convenience wrapper for hardware detection and console-friendly formatting.""" """Convenience wrapper for hardware detection and console-friendly formatting."""
info = get_accelerator_info_dict() info = get_accelerator_info_dict()
if info["type"] is None: if info["type"] is None:
@@ -350,6 +357,7 @@ def get_accelerator_info(include_warnings: bool = True) -> str:
def get_cpu_info_dict() -> dict[str, str | int | None]: def get_cpu_info_dict() -> dict[str, str | int | None]:
"""Gets granular CPU identifiers using the py-cpuinfo library.""" """Gets granular CPU identifiers using the py-cpuinfo library."""
info = cpuinfo.get_cpu_info() info = cpuinfo.get_cpu_info()
return { return {
@@ -363,6 +371,7 @@ def get_cpu_info_dict() -> dict[str, str | int | None]:
def get_cpu_info() -> str: def get_cpu_info() -> str:
"""Gets the CPU brand name.""" """Gets the CPU brand name."""
info = get_cpu_info_dict() info = get_cpu_info_dict()
parts = [] parts = []
parts.append( parts.append(
@@ -397,12 +406,14 @@ def get_python_env_info_dict() -> dict[str, str]:
def get_python_env_info() -> str: def get_python_env_info() -> str:
"""Detects the type of Python environment (Conda, Venv, etc.) and build info.""" """Detects the type of Python environment (Conda, Venv, etc.) and build info."""
info = get_python_env_info_dict() info = get_python_env_info_dict()
return f"{info['version']} ({info['implementation']}, {info['compiler']}) [{info['environment']}]" return f"{info['version']} ({info['implementation']}, {info['compiler']}) [{info['environment']}]"
def get_package_version(name: str) -> str | None: def get_package_version(name: str) -> str:
"""Gets the installed version of a package, stripping local suffixes like +cu128.""" """Gets the installed version of a package, stripping local suffixes like +cu128."""
# Normalize name: pip considers hyphens and underscores equivalent. # Normalize name: pip considers hyphens and underscores equivalent.
normalized_name = name.lower().replace("_", "-") normalized_name = name.lower().replace("_", "-")
version_str = importlib.metadata.version(normalized_name) version_str = importlib.metadata.version(normalized_name)
@@ -411,8 +422,12 @@ def get_package_version(name: str) -> str | None:
def get_requirements_dict() -> dict[str, str]: def get_requirements_dict() -> dict[str, str]:
"""Recursively finds all direct and transitive dependencies of heretic-llm and core libraries.""" """Recursively finds all direct and transitive dependencies of heretic-llm and core libraries."""
# We start with heretic-llm and the core compute libraries. # We start with heretic-llm and the core compute libraries.
# PyTorch is not listed as a dependency in the heretic-llm package
# because installation is hardware-specific and must be done manually.
packages_to_check = ["heretic-llm", "torch", "torchaudio", "torchvision"] packages_to_check = ["heretic-llm", "torch", "torchaudio", "torchvision"]
visited = set() visited = set()
required_packages = set() required_packages = set()
@@ -445,18 +460,19 @@ def get_requirements_dict() -> dict[str, str]:
# If a package is listed as a dependency but not installed, we skip it. # If a package is listed as a dependency but not installed, we skip it.
continue continue
required_packages_sorted = sorted(required_packages)
# Lookup versions for all discovered packages. # Lookup versions for all discovered packages.
dependencies = {} dependencies = {}
version_info = get_heretic_version_info() version_info = get_heretic_version_info()
for name in required_packages:
for package in required_packages_sorted:
# If heretic-llm was installed from source (Git/Local), exclude it # If heretic-llm was installed from source (Git/Local), exclude it
# from requirements.txt to prevent pip from downloading an unrelated # from requirements.txt to prevent pip from downloading an unrelated
# version from PyPI during reproduction. # version from PyPI during reproduction.
if name == "heretic-llm" and not version_info.is_standard_pypi: if package == "heretic-llm" and not version_info.is_standard_pypi:
continue continue
version_str = get_package_version(name) dependencies[package] = get_package_version(package)
if version_str:
dependencies[name] = version_str
return dependencies return dependencies
+263 -221
View File
@@ -9,6 +9,7 @@ import random
import tempfile import tempfile
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from importlib.metadata import version
from pathlib import Path from pathlib import Path
from typing import Any, TypeVar from typing import Any, TypeVar
@@ -155,18 +156,6 @@ def prompt_password(message: str) -> str:
return questionary.password(message).ask() return questionary.password(message).ask()
def prompt_confirm(message: str, default: bool = True) -> bool:
if is_notebook():
print()
choices = "[Y/n]" if default else "[y/N]"
result = input(f"{message} {choices} ").strip().lower()
if not result:
return default
return result in ("y", "yes")
else:
return questionary.confirm(message, default=default).ask()
def format_duration(seconds: float) -> str: def format_duration(seconds: float) -> str:
seconds = round(seconds) seconds = round(seconds)
hours, seconds = divmod(seconds, 3600) hours, seconds = divmod(seconds, 3600)
@@ -180,6 +169,18 @@ def format_duration(seconds: float) -> str:
return f"{seconds}s" return f"{seconds}s"
def is_hf_path(path: str) -> bool:
"""Checks whether a path likely refers to a Hugging Face repository."""
return (
not path.startswith("/")
and not path.endswith("/")
and path.count("/") == 1
and "\\" not in path
and not Path(path).exists()
)
@dataclass @dataclass
class Prompt: class Prompt:
system: str system: str
@@ -193,7 +194,13 @@ def load_prompts(
path = specification.dataset path = specification.dataset
split_str = specification.split split_str = specification.split
if os.path.isdir(path): if is_hf_path(path):
dataset = load_dataset(
path,
revision=specification.commit,
split=split_str,
)
else:
if Path(path, DATASET_STATE_JSON_FILENAME).exists(): if Path(path, DATASET_STATE_JSON_FILENAME).exists():
# Dataset saved with datasets.save_to_disk; needs special handling. # Dataset saved with datasets.save_to_disk; needs special handling.
# Path should be the subdirectory for a particular split. # Path should be the subdirectory for a particular split.
@@ -211,7 +218,7 @@ def load_prompts(
# Get the dataset by applying the indices. # Get the dataset by applying the indices.
dataset = dataset[abs_instruction.from_ : abs_instruction.to] dataset = dataset[abs_instruction.from_ : abs_instruction.to]
else: else:
# Path is a local directory. # Path should be a local directory.
dataset = load_dataset( dataset = load_dataset(
path, path,
split=split_str, split=split_str,
@@ -220,9 +227,6 @@ def load_prompts(
# But also don't use cached data, as the dataset may have changed on disk. # But also don't use cached data, as the dataset may have changed on disk.
download_mode=DownloadMode.FORCE_REDOWNLOAD, download_mode=DownloadMode.FORCE_REDOWNLOAD,
) )
else:
# Probably a repository path; let load_dataset figure it out.
dataset = load_dataset(path, split=split_str)
prompts = list(dataset[specification.column]) prompts = list(dataset[specification.column])
@@ -272,20 +276,28 @@ def get_trial_parameters(trial: Trial) -> dict[str, str]:
def get_readme_intro( def get_readme_intro(
settings: Settings, settings: Settings,
trial: Trial, trial: Trial,
base_refusals: int, contains_reproducibility_information: bool,
bad_prompts: list[Prompt],
) -> str: ) -> str:
if Path(settings.model).exists(): if is_hf_path(settings.model):
model_link = f"[{settings.model}](https://huggingface.co/{settings.model})"
else:
# Hide the path, which may contain private information. # Hide the path, which may contain private information.
model_link = "a model" model_link = "a model"
else:
model_link = f"[{settings.model}](https://huggingface.co/{settings.model})"
version_info = get_heretic_version_info() if contains_reproducibility_information:
reproducibility_instructions = """
> [!TIP]
> **This model is reproducible!**
>
> See the [README](reproduce/README.md) in the `reproduce` directory for more information.
"""
else:
reproducibility_instructions = ""
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_info.version} }, made using [Heretic](https://github.com/p-e-w/heretic) v{version("heretic-llm")}
{reproducibility_instructions}
## Abliteration parameters ## Abliteration parameters
| Parameter | Value | | Parameter | Value |
@@ -304,9 +316,9 @@ 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)* | | **KL divergence** | {trial.user_attrs["kl_divergence"]:.4f} | 0 *(by definition)* |
| **Refusals** | {trial.user_attrs["refusals"]}/{len(bad_prompts)} | {base_refusals}/{ | **Refusals** | {trial.user_attrs["refusals"]}/{trial.user_attrs["n_bad_prompts"]} | {
len(bad_prompts) trial.user_attrs["base_refusals"]
} | }/{trial.user_attrs["n_bad_prompts"]} |
----- -----
@@ -315,42 +327,54 @@ def get_readme_intro(
def generate_config_toml(settings: Settings) -> str: def generate_config_toml(settings: Settings) -> str:
"""Serializes the full Settings object to TOML.""" """Serializes the full Settings object to TOML."""
return tomli_w.dumps(settings.model_dump(exclude_none=True)) return tomli_w.dumps(settings.model_dump(exclude_none=True))
def generate_requirements_txt() -> str: def generate_requirements_txt() -> str:
"""Collects direct project dependencies as a formatted string.""" """Collects direct project dependencies as a formatted string."""
requirements = get_requirements_dict()
sorted_requirements = sorted( requirements = [
[f"{name}=={version}" for name, version in requirements.items()], f"{package}=={version}" for package, version in get_requirements_dict().items()
key=lambda x: x.lower(), ]
) return "\n".join(requirements) + "\n"
return "\n".join(sorted_requirements) + "\n"
def set_seed(seed: int): def set_seed(seed: int):
"""Sets the seed for all RNGs.""" """Sets the seed for all RNGs."""
random.seed(seed) random.seed(seed)
np.random.seed(seed) np.random.seed(seed)
torch.manual_seed(seed) torch.manual_seed(seed)
def format_hf_link(
path: str,
commit: str | None = None,
is_dataset: bool = False,
) -> str:
prefix = "datasets/" if is_dataset else ""
base_url = f"https://huggingface.co/{prefix}{path}"
link = f"[{path}]({base_url})"
if commit:
commit_url = f"{base_url}/commit/{commit}"
link += f" (Commit: [`{commit[:7]}`]({commit_url}))"
return link
def generate_reproduce_readme( def generate_reproduce_readme(
settings: Settings, settings: Settings,
checkpoint_filename: str, checkpoint_filename: str,
trial: Trial, trial: Trial,
timestamp: str | None = None, include_system_information: bool,
base_model_commit: str | None = None,
) -> str: ) -> str:
"""Generates a README.md for the reproduce/ folder.""" """Generates the contents of a README.md for the reproduce/ folder."""
torch_version = torch.__version__
install_hint = f"pip install torch=={torch_version}"
if "+" in torch_version:
suffix = torch_version.split("+")[1]
if suffix:
install_hint += f" --index-url https://download.pytorch.org/whl/{suffix}"
heterogeneous_warning = "" heterogeneous_warning = ""
if include_system_information:
if torch.cuda.is_available(): if torch.cuda.is_available():
count = torch.cuda.device_count() count = torch.cuda.device_count()
if count > 1: if count > 1:
@@ -358,207 +382,221 @@ def generate_reproduce_readme(
if len(device_names) > 1: if len(device_names) > 1:
heterogeneous_warning = """ heterogeneous_warning = """
> [!WARNING] > [!WARNING]
> **Heterogeneous GPUs Detected!** > **Heterogeneous GPUs**
> This system uses multiple non-identical GPUs. When operations are distributed across different GPUs (e.g. via `device_map='auto'`), non-deterministic behavior can occur. **Reproducibility ***cannot*** be guaranteed in this environment.** >
> This model was generated using multiple non-identical GPUs. When operations are distributed across different GPUs
> (e.g. via `device_map='auto'`), non-deterministic behavior can occur.
>
> Reproducibility *cannot* be guaranteed in this environment.
""" """
version_info = get_heretic_version_info()
origin_warning = ""
if not version_info.is_standard_pypi:
if version_info.origin and version_info.origin.startswith("Git"):
repo_info = version_info.origin.split("Git (")[1].strip(")")
origin_warning = f"""
> [!NOTE]
> **Git Installation Detected**
> This system installed `heretic-llm` from source repository: `{repo_info}`.
> To reproduce these results, you must install Heretic from this exact repository and commit.
"""
elif version_info.origin == "Local":
origin_warning = """
> [!WARNING]
> **Local Code Detected!**
> This system installed `heretic-llm` from a local directory or wheel. Uncommitted or experimental code may have been executed. **Reproducibility ***cannot*** be guaranteed in this environment.**
"""
else:
origin_warning = """
> [!WARNING]
> **Non-Standard Installation Detected!**
> This system installed `heretic-llm` from an unknown non-standard source. **Reproducibility ***cannot*** be guaranteed in this environment.**
"""
def format_hf_link(
name: str, commit: str | None = None, is_dataset: bool = False
) -> str:
if Path(name).exists():
return f"`{name}` (Local)"
prefix = "datasets/" if is_dataset else ""
base_url = f"https://huggingface.co/{prefix}{name}"
link = f"[{name}]({base_url})"
if commit:
commit_url = f"{base_url}/commit/{commit}"
link += f" (Commit: [{commit[:7]}]({commit_url}))"
return link
model_link = format_hf_link(settings.model, base_model_commit)
dataset_info = f"""## Dataset Information
- **Good Prompts:** {format_hf_link(settings.good_prompts.dataset, settings.good_prompts.commit, is_dataset=True)}
- **Bad Prompts:** {format_hf_link(settings.bad_prompts.dataset, settings.bad_prompts.commit, is_dataset=True)}
- **Good Evaluation Prompts:** {format_hf_link(settings.good_evaluation_prompts.dataset, settings.good_evaluation_prompts.commit, is_dataset=True)}
- **Bad Evaluation Prompts:** {format_hf_link(settings.bad_evaluation_prompts.dataset, settings.bad_evaluation_prompts.commit, is_dataset=True)}"""
timestamp_str = f"- **Run started at (UTC):** `{timestamp}`" if timestamp else ""
# System and Accelerator info using structured dictionaries.
cpu = get_cpu_info_dict() cpu = get_cpu_info_dict()
python_env = get_python_env_info_dict() python_env = get_python_env_info_dict()
accelerator = get_accelerator_info_dict()
# Build System Environment section. accelerators = get_accelerator_info_dict()
system_env_lines = [ if accelerators["type"] is None:
f"- **OS:** `{platform.platform()}` (`{platform.machine()}`)", accelerator_report = "**No GPU or other accelerator detected.**"
f"- **CPU:** `{cpu['brand'] or 'Unknown CPU'}`",
f" - **Information:** Family `{cpu['family']}`, Model `{cpu['model']}`, Stepping `{cpu['stepping']}`",
]
system_env_lines.extend(
[
f"- **Python:** `{python_env['version']}` (`{python_env['implementation']}`, `{python_env['compiler']}`) [`{python_env['environment']}`]",
f"- **Heretic:** `v{version_info.version}`"
+ (f" (Origin: `{version_info.origin}`)" if version_info.origin else ""),
f"- **PyTorch:** `{torch.__version__}`",
]
)
system_environment_report = "\n".join(system_env_lines)
# Build Accelerators section.
if accelerator["type"] is None:
accelerator_report = "> [!WARNING]\n> **No GPU or other accelerator detected.**"
else: else:
devices = accelerator["devices"] devices = accelerators["devices"]
total_vram = sum(d.get("vram_gb", 0) for d in devices) total_vram = sum(device.get("vram_gb", 0) for device in devices)
vram_suffix = f" (`{total_vram:.2f} GB` total VRAM)" if total_vram > 0 else "" vram_suffix = f" ({total_vram:.2f} GB total VRAM)" if total_vram > 0 else ""
accelerator_lines = [ accelerator_lines = [
f"- **{accelerator['type']}:** Detected `{len(devices)}` device(s){vram_suffix}" f"- **{accelerators['type']}:** Detected {len(devices)} device(s){vram_suffix}"
] ]
if accelerator.get("api_name") and accelerator.get("api_version"): if accelerators.get("api_name") and accelerators.get("api_version"):
accelerator_lines.append( accelerator_lines.append(
f" - **{accelerator['api_name']}:** `{accelerator['api_version']}`" f" - **{accelerators['api_name']}:** {accelerators['api_version']}"
) )
if accelerator.get("driver_version"): if accelerators.get("driver_version"):
accelerator_lines.append( accelerator_lines.append(
f" - **Driver Version:** `{accelerator['driver_version']}`" f" - **Driver Version:** {accelerators['driver_version']}"
) )
accelerator_lines.append("- **Devices:**") accelerator_lines.append("- **Devices:**")
for i, dev in enumerate(devices): for i, device in enumerate(devices):
vram = f" (`{dev['vram_gb']:.2f} GB`)" if dev.get("vram_gb") else "" vram = f" ({device['vram_gb']:.2f} GB)" if device.get("vram_gb") else ""
accelerator_lines.append( accelerator_lines.append(
f" - **{accelerator['type']} {i}:** `{dev['name']}`{vram}" f" - **{accelerators['type']} {i}:** {device['name']}{vram}"
) )
accelerator_report = "\n".join(accelerator_lines) accelerator_report = "\n".join(accelerator_lines)
return f"""# Reproduction Guide system_report = f"""## System
This directory contains the necessary information and assets to reproduce the results obtained during this Heretic run.{heterogeneous_warning}{origin_warning} - **Python:** {python_env["version"]} ({python_env["implementation"]}, {python_env["compiler"]}) [{python_env["environment"]}]
- **Operating system:** {platform.platform()} ({platform.machine()})
## Model Information - **CPU:** {cpu["brand"] or "Unknown"}
- **Base Model:** {model_link}
{timestamp_str}
{dataset_info}
## Selected Trial
- **Trial Number:** `#{trial.user_attrs["index"]}`
- **Refusal Count:** `{trial.user_attrs.get("refusals")}/{trial.user_attrs.get("total_refusal_prompts")}`
- **KL Divergence:** `{trial.user_attrs.get("kl_divergence", 0):.6f}`
## System Environment
{system_environment_report}
### Accelerators ### Accelerators
{accelerator_report} {accelerator_report}
## Contents """
system_instructions = (
"1. Ensure your system matches the specifications in the **System** section above. "
"Exact reproducibility is only guaranteed if all aspects of your system are identical to the one the model was originally generated on.\n"
)
else:
system_report = ""
system_instructions = ""
- **config.toml**: The exact configuration used, including the seed `{settings.seed}`. version_info = get_heretic_version_info()
- **requirements.txt**: The exact versions of all installed Python packages. origin_warning = ""
- **{checkpoint_filename}**: The Optuna study journal containing the history of all trials. if not version_info.is_standard_pypi:
- **reproduce.json**: A machine-readable version of this report. if version_info.origin and version_info.origin.startswith("Git"):
- **SHA256SUMS**: Cryptographic hashes for all uploaded weight files (if applicable). repo_info = version_info.origin.split("Git (")[1].rstrip(")")
origin_warning = f"""
> [!IMPORTANT]
> **Git installation**
>
> This system installed Heretic from a Git repository: {repo_info}
>
> To reproduce the model, you must install Heretic from this exact repository and commit.
"""
elif version_info.origin == "Local":
origin_warning = """
> [!WARNING]
> **Local code**
>
> This system installed Heretic from a local directory or wheel. Uncommitted or experimental code may have been executed.
>
> Reproducibility *cannot* be guaranteed in this environment.
"""
else:
origin_warning = """
> [!WARNING]
> **Non-standard installation**
>
> This system installed Heretic from an unknown non-standard source.
>
> Reproducibility *cannot* be guaranteed in this environment.
"""
## How to Reproduce pytorch_version = torch.__version__
pytorch_install_command = f"pip install torch=={pytorch_version}"
if "+" in pytorch_version:
suffix = pytorch_version.split("+")[1]
if suffix:
pytorch_install_command += (
f" --index-url https://download.pytorch.org/whl/{suffix}"
)
1. Ensure your hardware and environment match the specifications in the **System Environment** section above. return f"""# Reproduction guide
2. Install the exact package versions listed in `requirements.txt`.
3. Place the provided `config.toml` in your working directory. This directory contains the necessary information and assets to reproduce the results obtained during this Heretic run.{heterogeneous_warning}{origin_warning}
4. Run `heretic` without any additional arguments.
5. Verify the integrity of the reproduced files by comparing their SHA256 hashes against the manifest in `SHA256SUMS`. ## Models
- **Base model:** {format_hf_link(settings.model, settings.model_commit)}
## Datasets
- **Good prompts:** {format_hf_link(settings.good_prompts.dataset, settings.good_prompts.commit, is_dataset=True)}
- **Bad prompts:** {format_hf_link(settings.bad_prompts.dataset, settings.bad_prompts.commit, is_dataset=True)}
- **Good evaluation prompts:** {format_hf_link(settings.good_evaluation_prompts.dataset, settings.good_evaluation_prompts.commit, is_dataset=True)}
- **Bad evaluation prompts:** {format_hf_link(settings.bad_evaluation_prompts.dataset, settings.bad_evaluation_prompts.commit, is_dataset=True)}
## Selected trial
- **Trial number:** {trial.user_attrs["index"]}
- **KL divergence:** {trial.user_attrs["kl_divergence"]:.6f}
- **Refusals:** {trial.user_attrs["refusals"]}/{trial.user_attrs["n_bad_prompts"]}
{system_report}## Environment
- **Heretic:** v{version_info.version}{f" (Origin: {version_info.origin})" if version_info.origin else ""}
- **PyTorch:** {pytorch_version}
- **Other dependencies:** See [`requirements.txt`](requirements.txt).
## Contents of this directory
- [`requirements.txt`](requirements.txt): The exact versions of all Python packages.
- [`config.toml`](config.toml): The exact configuration used, including the RNG seed.
- [`{checkpoint_filename}`]({checkpoint_filename}): The Optuna study journal containing the history of all trials.
- [`SHA256SUMS`](SHA256SUMS): Cryptographic hashes for all weight files.
- [`reproduce.json`](reproduce.json): A machine-readable file containing all reproducibility information.
## How to reproduce
{system_instructions}1. Install the exact version of Heretic indicated in the **Environment** section above, from its original source.
1. Install the packages listed in `requirements.txt`: `pip install -r requirements.txt`
1. Install the correct version of PyTorch: `{pytorch_install_command}`
1. Place the provided `config.toml` in your working directory.
1. Run Heretic without any additional arguments: `heretic`
1. Wait for the run to finish, then select trial **{trial.user_attrs["index"]}** and export the model.
1. Verify that the weight files have been exactly reproduced by comparing their SHA-256 hashes against those in `SHA256SUMS`: `sha256sum -c SHA256SUMS` (or look at the hashes online if you uploaded to Hugging Face)
> [!TIP] > [!TIP]
> To use the included Optuna study journal `{checkpoint_filename}`, place it in a `checkpoints/` directory before running `heretic` on the same model. > To use the included Optuna study journal `{checkpoint_filename}`, place it in the checkpoints directory (usually `checkpoints/`) before running Heretic.
>
> [!IMPORTANT] > This allows you to export other models from the Pareto front, or to run additional trials without having to re-run the stored trials.
> Make sure to install correct PyTorch version from: `{install_hint}`
""" """
def generate_reproduce_json( def generate_reproduce_json(
settings: Settings, settings: Settings,
trial: Trial, trial: Trial,
timestamp: str | None = None, timestamp: str,
base_model_commit: str | None = None, uploaded_model_hashes: dict[str, str],
uploaded_model_hashes: dict[str, str] | None = None, include_system_information: bool,
) -> str: ) -> str:
"""Generates a reproduce.json file for the reproduce/ folder.""" """Generates the contents of a reproduce.json file for the reproduce/ folder."""
version_info = get_heretic_version_info() version_info = get_heretic_version_info()
data = { data = {
"base_model": { "version": "1", # Version number of the reproduce.json file format, to allow for future changes.
"id": settings.model, "timestamp": timestamp,
"commit_hash": base_model_commit, "system": None, # Defined here to preserve insertion order.
}, "environment": {
"system": {
"os": {"platform": platform.platform(), "machine": platform.machine()},
"cpu": get_cpu_info_dict(),
"python": get_python_env_info_dict(),
"heretic": { "heretic": {
"version": version_info.version, "version": version_info.version,
"is_standard_pypi": version_info.is_standard_pypi, "is_standard_pypi": version_info.is_standard_pypi,
"metadata": version_info.metadata, "metadata": version_info.metadata,
}, },
"pytorch_version": torch.__version__, "pytorch_version": torch.__version__,
"accelerator": get_accelerator_info_dict(),
},
"requirements": get_requirements_dict(), "requirements": get_requirements_dict(),
"settings": settings.model_dump(exclude_none=True), },
"trial": { "settings": settings.model_dump(),
"direction_index": trial.user_attrs.get("direction_index"), "parameters": {
"parameters": trial.user_attrs.get("parameters"), "direction_index": trial.user_attrs["direction_index"],
"abliteration_parameters": trial.user_attrs["parameters"],
},
"metrics": { "metrics": {
"refusals": trial.user_attrs.get("refusals"), "kl_divergence": trial.user_attrs["kl_divergence"],
"total_refusal_prompts": trial.user_attrs.get("total_refusal_prompts"), "refusals": trial.user_attrs["refusals"],
"kl_divergence": trial.user_attrs.get("kl_divergence"), "base_refusals": trial.user_attrs["base_refusals"],
"n_bad_prompts": trial.user_attrs["n_bad_prompts"],
}, },
}, "hashes": uploaded_model_hashes,
"timestamp": timestamp,
"uploaded_model_hashes": uploaded_model_hashes or {},
} }
if include_system_information:
data["system"] = {
"python": get_python_env_info_dict(),
"os": {
"platform": platform.platform(),
"machine": platform.machine(),
},
"cpu": get_cpu_info_dict(),
"accelerators": get_accelerator_info_dict(),
}
else:
del data["system"]
return json.dumps(data, indent=4) return json.dumps(data, indent=4)
def generate_sha256sums(hashes: dict[str, str]) -> str: def generate_sha256sums(hashes: dict[str, str]) -> str:
"""Generates a GNU Coreutils compatible SHA256SUMS file content.""" """Generates GNU Coreutils compatible SHA256SUMS file content."""
lines = [] lines = []
for filename, sha256 in sorted(hashes.items()): for filename, sha256 in sorted(hashes.items()):
# Use '*' to indicate binary mode for model weights. # Use '*' to indicate binary mode for model weights.
lines.append(f"{sha256} *{filename}") lines.append(f"{sha256} *{filename}")
return "\n".join(lines) + "\n" return "\n".join(lines) + "\n"
@@ -567,13 +605,17 @@ def create_reproduce_folder(
settings: Settings, settings: Settings,
checkpoint_path: str | Path, checkpoint_path: str | Path,
trial: Trial, trial: Trial,
uploaded_model_hashes: dict[str, str] | None = None, uploaded_model_hashes: dict[str, str],
) -> None: include_system_information: bool,
):
reproduce_dir = path / "reproduce" reproduce_dir = path / "reproduce"
reproduce_dir.mkdir(parents=True, exist_ok=True) reproduce_dir.mkdir(parents=True, exist_ok=True)
checkpoint_filename = Path(checkpoint_path).name checkpoint_filename = Path(checkpoint_path).name
# Fetch commit hash for the base model.
settings.model_commit = huggingface_hub.model_info(settings.model).sha
# Fetch commit hashes for all HF datasets to ensure reproducibility. # Fetch commit hashes for all HF datasets to ensure reproducibility.
for spec in [ for spec in [
settings.good_prompts, settings.good_prompts,
@@ -581,50 +623,46 @@ def create_reproduce_folder(
settings.good_evaluation_prompts, settings.good_evaluation_prompts,
settings.bad_evaluation_prompts, settings.bad_evaluation_prompts,
]: ]:
if not Path(spec.dataset).exists():
# Fail if the dataset is missing or unreachable.
spec.commit = huggingface_hub.dataset_info(spec.dataset).sha spec.commit = huggingface_hub.dataset_info(spec.dataset).sha
# Fetch commit hash for the base model if it's on HF.
base_model_commit = None
if not Path(settings.model).exists():
try:
base_model_commit = huggingface_hub.model_info(settings.model).sha
except Exception:
pass
# Strip microseconds and timezone for a clean format. # Strip microseconds and timezone for a clean format.
timestamp = ( timestamp = (
datetime.now(timezone.utc).replace(microsecond=0, tzinfo=None).isoformat() datetime.now(timezone.utc).replace(microsecond=0, tzinfo=None).isoformat()
) )
(reproduce_dir / "config.toml").write_text(
generate_config_toml(settings), encoding="utf-8"
)
(reproduce_dir / "requirements.txt").write_text( (reproduce_dir / "requirements.txt").write_text(
generate_requirements_txt(), encoding="utf-8" generate_requirements_txt(),
)
(reproduce_dir / "README.md").write_text(
generate_reproduce_readme(
settings,
checkpoint_filename,
trial,
timestamp=timestamp,
base_model_commit=base_model_commit,
),
encoding="utf-8", encoding="utf-8",
) )
(reproduce_dir / "config.toml").write_text(
generate_config_toml(settings),
encoding="utf-8",
)
if uploaded_model_hashes: if uploaded_model_hashes:
(reproduce_dir / "SHA256SUMS").write_text( (reproduce_dir / "SHA256SUMS").write_text(
generate_sha256sums(uploaded_model_hashes), encoding="utf-8" generate_sha256sums(uploaded_model_hashes),
encoding="utf-8",
) )
(reproduce_dir / "reproduce.json").write_text( (reproduce_dir / "reproduce.json").write_text(
generate_reproduce_json( generate_reproduce_json(
settings, settings,
trial, trial,
timestamp=timestamp, timestamp=timestamp,
base_model_commit=base_model_commit,
uploaded_model_hashes=uploaded_model_hashes, uploaded_model_hashes=uploaded_model_hashes,
include_system_information=include_system_information,
),
encoding="utf-8",
)
(reproduce_dir / "README.md").write_text(
generate_reproduce_readme(
settings,
checkpoint_filename,
trial,
include_system_information=include_system_information,
), ),
encoding="utf-8", encoding="utf-8",
) )
@@ -641,22 +679,25 @@ def upload_reproduce_folder(
token: str, token: str,
checkpoint_path: str | Path, checkpoint_path: str | Path,
trial: Trial, trial: Trial,
) -> None: include_system_information: bool,
uploaded_model_hashes = {} ):
try:
api = huggingface_hub.HfApi() api = huggingface_hub.HfApi()
info = api.model_info(repo_id=repo_id, files_metadata=True, token=token) info = api.model_info(repo_id=repo_id, files_metadata=True, token=token)
if not info.siblings:
raise RuntimeError("Could not fetch uploaded model hashes.")
# For weights, we only care about safetensors. # For weights, we only care about safetensors.
weight_extensions = (".safetensors",) weight_extensions = (".safetensors",)
if info.siblings is not None:
uploaded_model_hashes = {}
for file in info.siblings: for file in info.siblings:
if file.rfilename.endswith(weight_extensions): if file.rfilename.endswith(weight_extensions):
sha256 = getattr(file, "lfs", {}).get("sha256") sha256 = getattr(file, "lfs", {}).get("sha256")
if sha256: if not sha256:
raise RuntimeError("Could not fetch uploaded model hashes.")
uploaded_model_hashes[file.rfilename] = sha256 uploaded_model_hashes[file.rfilename] = sha256
except Exception as e:
# Fail if integrity checks cannot be completed.
raise RuntimeError(f"Could not fetch uploaded model hashes: {e}") from e
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir) tmp_path = Path(tmpdir)
@@ -666,6 +707,7 @@ def upload_reproduce_folder(
checkpoint_path=checkpoint_path, checkpoint_path=checkpoint_path,
trial=trial, trial=trial,
uploaded_model_hashes=uploaded_model_hashes, uploaded_model_hashes=uploaded_model_hashes,
include_system_information=include_system_information,
) )
reproduce_dir = tmp_path / "reproduce" reproduce_dir = tmp_path / "reproduce"
Generated
+27 -17
View File
@@ -8,7 +8,7 @@ resolution-markers = [
] ]
[options] [options]
exclude-newer = "2026-04-14T22:48:57.86057843Z" exclude-newer = "2026-04-28T12:47:55.130721483Z"
exclude-newer-span = "P7D" exclude-newer-span = "P7D"
[[package]] [[package]]
@@ -931,7 +931,7 @@ wheels = [
[[package]] [[package]]
name = "heretic-llm" name = "heretic-llm"
version = "1.2.0" version = "1.3.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "accelerate" }, { name = "accelerate" },
@@ -983,14 +983,14 @@ requires-dist = [
{ name = "huggingface-hub", specifier = "~=1.7" }, { name = "huggingface-hub", specifier = "~=1.7" },
{ name = "imageio", marker = "extra == 'research'", specifier = "~=2.37" }, { name = "imageio", marker = "extra == 'research'", specifier = "~=2.37" },
{ name = "immutabledict", specifier = "~=4.3" }, { name = "immutabledict", specifier = "~=4.3" },
{ name = "kernels", specifier = "~=0.12" }, { name = "kernels", specifier = "~=0.13" },
{ name = "langdetect", specifier = "~=1.0" }, { name = "langdetect", specifier = "~=1.0" },
{ name = "lm-eval", extras = ["hf"], specifier = "~=0.4" }, { name = "lm-eval", extras = ["hf"], specifier = "~=0.4" },
{ name = "matplotlib", marker = "extra == 'research'", specifier = "~=3.10" }, { name = "matplotlib", marker = "extra == 'research'", specifier = "~=3.10" },
{ name = "numpy", specifier = "~=2.2" }, { name = "numpy", specifier = "~=2.2" },
{ name = "optuna", specifier = "~=4.7" }, { name = "optuna", specifier = "~=4.7" },
{ name = "pacmap", marker = "extra == 'research'", specifier = "~=0.8" }, { name = "pacmap", marker = "extra == 'research'", specifier = "~=0.8" },
{ name = "peft", specifier = "~=0.18" }, { name = "peft", specifier = "~=0.19" },
{ name = "psutil", specifier = "~=7.2" }, { name = "psutil", specifier = "~=7.2" },
{ name = "py-cpuinfo", specifier = "~=9.0" }, { name = "py-cpuinfo", specifier = "~=9.0" },
{ name = "pydantic-settings", specifier = "~=2.13" }, { name = "pydantic-settings", specifier = "~=2.13" },
@@ -999,7 +999,7 @@ requires-dist = [
{ name = "scikit-learn", marker = "extra == 'research'", specifier = "~=1.7" }, { name = "scikit-learn", marker = "extra == 'research'", specifier = "~=1.7" },
{ name = "tomli-w", specifier = "~=1.2" }, { name = "tomli-w", specifier = "~=1.2" },
{ name = "tqdm", specifier = "~=4.67" }, { name = "tqdm", specifier = "~=4.67" },
{ name = "transformers", specifier = "~=5.3" }, { name = "transformers", specifier = "~=5.6" },
] ]
provides-extras = ["research"] provides-extras = ["research"]
@@ -1188,17 +1188,18 @@ wheels = [
[[package]] [[package]]
name = "kernels" name = "kernels"
version = "0.12.3" version = "0.13.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "huggingface-hub" }, { name = "huggingface-hub" },
{ name = "packaging" }, { name = "packaging" },
{ name = "pyyaml" }, { name = "pyyaml" },
{ name = "tomli", marker = "python_full_version < '3.11'" }, { name = "tomli", marker = "python_full_version < '3.11'" },
{ name = "tomlkit" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/b3/84/9f68f355f6ce99e977872021fbdbafadcf2820f51d3f7bd697ec3801cb7a/kernels-0.12.3.tar.gz", hash = "sha256:87e29716578e7e71dc5a7578e0132bfdae305bedaeb602698f87c88ca6c60e32", size = 57407, upload-time = "2026-03-20T10:20:42.166Z" } sdist = { url = "https://files.pythonhosted.org/packages/3e/0d/e9c158c527a7b51382fe816a7b7e60caae17ff1153640c1803211a067c99/kernels-0.13.0.tar.gz", hash = "sha256:bf7908206009bff0017d09b87f0f6b5934a1a20520562caf1cbb06cab36418cc", size = 74755, upload-time = "2026-04-10T14:30:45.356Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/3e/778e4a86830e9139df2d16d86c4488fce426ec19daa83cbd2854ef389030/kernels-0.12.3-py3-none-any.whl", hash = "sha256:5d1d33fcb774e03bb7f0688ac24d91ef6b963692f80f0a85ddd2286e69f3cf2f", size = 55501, upload-time = "2026-03-20T10:20:40.643Z" }, { url = "https://files.pythonhosted.org/packages/b3/45/2cb29e965c199ab01151fee24cbb57b23550c9e6bc897ca242b1e4b8c4bf/kernels-0.13.0-py3-none-any.whl", hash = "sha256:5d857ee4e06dc7496bcd59c4756e84eb71c019b34524dea58ccb0eaaae3bb6df", size = 69177, upload-time = "2026-04-10T14:30:43.551Z" },
] ]
[[package]] [[package]]
@@ -1508,14 +1509,14 @@ wheels = [
[[package]] [[package]]
name = "mako" name = "mako"
version = "1.3.10" version = "1.3.11"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "markupsafe" }, { name = "markupsafe" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" } sdist = { url = "https://files.pythonhosted.org/packages/59/8a/805404d0c0b9f3d7a326475ca008db57aea9c5c9f2e1e39ed0faa335571c/mako-1.3.11.tar.gz", hash = "sha256:071eb4ab4c5010443152255d77db7faa6ce5916f35226eb02dc34479b6858069", size = 399811, upload-time = "2026-04-14T20:19:51.493Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" }, { url = "https://files.pythonhosted.org/packages/68/a5/19d7aaa7e433713ffe881df33705925a196afb9532efc8475d26593921a6/mako-1.3.11-py3-none-any.whl", hash = "sha256:e372c6e333cf004aa736a15f425087ec977e1fcbd2966aae7f17c8dc1da27a77", size = 78503, upload-time = "2026-04-14T20:19:53.233Z" },
] ]
[[package]] [[package]]
@@ -2365,7 +2366,7 @@ wheels = [
[[package]] [[package]]
name = "peft" name = "peft"
version = "0.18.0" version = "0.19.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "accelerate" }, { name = "accelerate" },
@@ -2380,9 +2381,9 @@ dependencies = [
{ name = "tqdm" }, { name = "tqdm" },
{ name = "transformers" }, { name = "transformers" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/4b/0c/f2938db546ac7fc961ab5917cd50fcf5d0d70b406de93e3faccaa504e152/peft-0.18.0.tar.gz", hash = "sha256:c81c80b2056ab40c23d58ef25f74daab417ac653970718589a11a8af28218588", size = 634141, upload-time = "2025-11-13T11:13:06.603Z" } sdist = { url = "https://files.pythonhosted.org/packages/86/cf/037f1e3d5186496c05513a6754639e2dab3038a05f384284d49a9bd06a2d/peft-0.19.1.tar.gz", hash = "sha256:0d97542fe96dcdaa20d3b81c06f26f988618f416a73544ab23c3618ccb674a40", size = 763738, upload-time = "2026-04-16T15:46:45.105Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/0f/55/481bf25613d40ef53534f664deba7b138fe566356b6ca10304e2b3b2529c/peft-0.18.0-py3-none-any.whl", hash = "sha256:624f69ca6393b765ccc6734adda7ca57d80b238f0900a42c357d8b67a03d62ff", size = 556427, upload-time = "2025-11-13T11:13:03.664Z" }, { url = "https://files.pythonhosted.org/packages/e8/b6/f54d676ed93cc2dd2234c3b172ea9c8c3d7d29361e66b1b23dec57a67465/peft-0.19.1-py3-none-any.whl", hash = "sha256:2113f72a81621b5913ef28f9022204c742df111890c5f49d812716a4a301e356", size = 680692, upload-time = "2026-04-16T15:46:42.886Z" },
] ]
[[package]] [[package]]
@@ -3696,6 +3697,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" },
] ]
[[package]]
name = "tomlkit"
version = "0.14.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/af/14b24e41977adb296d6bd1fb59402cf7d60ce364f90c890bd2ec65c43b5a/tomlkit-0.14.0.tar.gz", hash = "sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064", size = 187167, upload-time = "2026-01-13T01:14:53.304Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680", size = 39310, upload-time = "2026-01-13T01:14:51.965Z" },
]
[[package]] [[package]]
name = "torch" name = "torch"
version = "2.9.1" version = "2.9.1"
@@ -3771,7 +3781,7 @@ wheels = [
[[package]] [[package]]
name = "transformers" name = "transformers"
version = "5.3.0" version = "5.6.2"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "huggingface-hub" }, { name = "huggingface-hub" },
@@ -3785,9 +3795,9 @@ dependencies = [
{ name = "tqdm" }, { name = "tqdm" },
{ name = "typer" }, { name = "typer" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/fc/1a/70e830d53ecc96ce69cfa8de38f163712d2b43ac52fbd743f39f56025c31/transformers-5.3.0.tar.gz", hash = "sha256:009555b364029da9e2946d41f1c5de9f15e6b1df46b189b7293f33a161b9c557", size = 8830831, upload-time = "2026-03-04T17:41:46.119Z" } sdist = { url = "https://files.pythonhosted.org/packages/a4/e9/c6c80a07690142a7d05444271f47b9f3c8aac7dea01d52e1137ee480ad78/transformers-5.6.2.tar.gz", hash = "sha256:e657134c3e5a6bc00a3c35f4e2674bb51adfcd89898495b788a18552bac2b91a", size = 8311867, upload-time = "2026-04-23T18:33:29.332Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/b8/88/ae8320064e32679a5429a2c9ebbc05c2bf32cefb6e076f9b07f6d685a9b4/transformers-5.3.0-py3-none-any.whl", hash = "sha256:50ac8c89c3c7033444fb3f9f53138096b997ebb70d4b5e50a2e810bf12d3d29a", size = 10661827, upload-time = "2026-03-04T17:41:42.722Z" }, { url = "https://files.pythonhosted.org/packages/5d/95/0b0218149b0d6f14df35f5b8f676fa83df4f19ed253c3cc447107ef86eca/transformers-5.6.2-py3-none-any.whl", hash = "sha256:f8d3a1bb96778fed9b8aabfd0dd6e19843e4b0f2bb6b59f32b8a92051b0f348f", size = 10364898, upload-time = "2026-04-23T18:33:26.081Z" },
] ]
[[package]] [[package]]