68 Commits

Author SHA1 Message Date
Ashar edc3b12345 fix(ara): free gradient buffers after optimization (#426)
LBFGS leaves one full-size gradient buffer on every optimized weight.
Across the layers processed in a typical trial this is many GiB of VRAM
that persists into evaluation, causing CUDA out-of-memory errors.
Clear the gradients after each module is optimized.
2026-08-17 14:57:09 +05:30
kabachuha 25979ad7d0 feat: ARA, but it's LoRA (#332)
* ARA, but it's LoRA

* ARA, but it's LoRA: address Gemini's review

* ARA, but it's LoRA: Gemini is stupid
2026-07-05 13:51:05 +05:30
Philipp Emanuel Weidmann 3b70fe5dfa fix(ara): set batch size on HFLM object 2026-04-01 14:34:21 +05:30
Philipp Emanuel Weidmann f7a456bd0c feat(ara): add optional row-norm preservation 2026-03-31 15:55:27 +05:30
Philipp Emanuel Weidmann 988c6bd90e Merge branch 'master' into ara 2026-03-30 13:28:57 +05:30
Philipp Emanuel Weidmann 96c7a7d98a fix: replace tqdm progress bars with Rich progress bars 2026-03-28 18:30:15 +05:30
Philipp Emanuel Weidmann 1126332281 feat: add integrated benchmarking system 2026-03-24 18:25:12 +05:30
Philipp Emanuel Weidmann c925f5e802 feat(ara): add option to optimize for PIQA instead of KLD 2026-03-21 13:21:43 +05:30
Philipp Emanuel Weidmann 19cdf7e244 fix: address ty complaint 2026-03-15 09:58:00 +05:30
Philipp Emanuel Weidmann 94775d4148 chore: update dependencies 2026-03-15 09:31:32 +05:30
cpagac 515a7b9eb5 fix: prevent div-by-zero in evaluator when base_refusals is 0 (#225)
* fix: prevent div-by-zero in evaluator when base_refusals is 0

When a model refuses all prompts from the start, base_refusals is 0.
Return refusals directly in that case so ablations that introduce new
refusals are still penalized correctly.

* fix: cast refusals to float for type consistency" before hitting commit changes

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-03-13 11:21:23 +05:30
erm14254 e26da5e0e6 fix: display all abliterable components across layers (#215)
* fix: display all abliterable components across layers

The current code only displays abliterable components from layer 0, which is misleading for hybrid architectures like Qwen3.5 that use different attention types across layers (e.g., `linear_attn.out_proj` in some layers, `self_attn.o_proj` in others).

This fix iterates through all layers to collect and display the complete set of abliterable components with accurate module counts.

Before (Qwen3.5-27B):
* attn.out_proj: 1 modules per layer
* mlp.down_proj: 1 modules per layer

After (Qwen3.5-27B):
* attn.out_proj: 48 modules total
* attn.o_proj: 16 modules total
* mlp.down_proj: 64 modules total

* Fix formatting

---------

Co-authored-by: Lawfer12 <ac728@ymail.com>
2026-03-11 14:10:37 +05:30
Philipp Emanuel Weidmann 4a6304c361 feat(ara): add abliteration method to model card 2026-03-10 11:16:09 +05:30
Philipp Emanuel Weidmann c76416fe03 feat(ara): expand parameter ranges
Incorporates feedback from @joninco
2026-03-10 10:27:32 +05:30
joninco 2bb203ee47 fix(ara): store captured I/O tensors on CPU for multi-GPU robustness (#214)
Extends d79a443 — that commit correctly moves I/O tensors to the weight
matrix's device before L-BFGS optimization, but the captured tensors
remain on their original GPU between trials. When reset_model() reloads
the model, device assignments can change, leaving orphaned tensors on
GPUs that now need that VRAM for the reloaded weights.

Moving to CPU at capture time in get_module_io ensures:
- Zero VRAM wasted on stale device assignments between trials
- Clean CPU→target transfer regardless of how devices shuffle on reload
- No overhead on single-GPU (.cpu() is a no-op when already on CPU,
  and .to(device) in ara_abliterate handles the final placement)
2026-03-07 19:40:45 +05:30
Philipp Emanuel Weidmann d79a443e6f feat(ara): fix issues on some multi-GPU setups
Co-authored-by: kabachuha <artemkhrapov2001@yandex.ru>
2026-03-07 18:24:31 +05:30
Philipp Emanuel Weidmann ec0367226d style: fix formatting and naming 2026-03-06 13:18:08 +05:30
Matthias Stegner 5e3c04c802 feat: add Qwen3.5 MoE hybrid layer support (#187)
* feat: add Qwen3.5 MoE hybrid layer support

Qwen3.5 MoE uses GatedDeltaNet (linear attention) on some layers instead
of standard self-attention, causing abliteration to fail because
self_attn.o_proj doesn't exist on those layers.

Changes:
- Wrap self_attn.o_proj in suppress(Exception) and add linear_attn.out_proj
  as alternative attention out-projection for GatedDeltaNet layers
- Scan all layers in get_abliterable_components() instead of only layer 0,
  since hybrid models have different components on different layers
- Derive LoRA target_modules from actual named_modules() instead of
  splitting component keys, which fails when module names differ across
  layers (e.g. "o_proj" vs "out_proj")

Tested with Qwen3.5-397B-A17B (7/100 refusals, KL 0.2676).

Relates to #43

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Apply suggestion from @gemini-code-assist[bot]

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Philipp Emanuel Weidmann <pew@worldwidemann.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-03-06 13:03:57 +05:30
Philipp Emanuel Weidmann 0bb9521fbe feat(ara): optimize all parameters 2026-03-05 08:55:58 +05:30
Philipp Emanuel Weidmann 992fb3a4b3 feat(ara): change weights to match those used for the gpt-oss-20b demo 2026-03-04 16:48:25 +05:30
Philipp Emanuel Weidmann 304c14adc7 feat(ara): replace weight parameters with balance parameter 2026-03-04 11:59:46 +05:30
Philipp Emanuel Weidmann 56e57adf36 feat(ara): improve steering term 2026-03-04 11:06:01 +05:30
Philipp Emanuel Weidmann bd1fa0ade4 feat(ara): remove tie_to_original_matrix term
This term was found experimentally to be 3-4 orders of magnitude smaller than the others in most runs, and have no meaningful effect on the result of the optimization.
2026-03-04 09:13:38 +05:30
Philipp Emanuel Weidmann 3c5d6920bf feat(ara): fix memory leak 2026-03-02 18:50:27 +05:30
Philipp Emanuel Weidmann b8f4a9c985 feat(ara): implement optimization for ARA parameters 2026-03-02 14:36:57 +05:30
Philipp Emanuel Weidmann 154241f8a2 feat(ara): implement matrix optimization 2026-02-27 14:16:27 +05:30
Spiky Moth 303ba9d978 fix: recheck prefix after inserting predefined (#194) 2026-02-27 08:07:33 +05:30
Philipp Emanuel Weidmann ea7c59a55a feat(ara): add methods for obtaining module I/O 2026-02-25 17:44:46 +05:30
Philipp Emanuel Weidmann cb4ef3fdfc docs: add Trendshift badge to README 2026-02-20 13:00:19 +05:30
cpagac 4c80c4beb9 fix: report VRAM usage across all GPUs instead of only the default device (#169)
memory_allocated() and memory_reserved() without a device argument only
report GPU 0. Sum across all devices for correct multi-GPU totals and
add total VRAM reporting.
2026-02-17 12:53:41 +05:30
Spiky Moth 3a115e280c fix: produce card for local models with existing readme (#157) 2026-02-15 19:10:10 +05:30
Philipp Emanuel Weidmann 27097bfe8e build: bump version to 1.2.0 2026-02-14 18:11:42 +05:30
Philipp Emanuel Weidmann 025ab3a881 fix: disable LoRA export for now
Workaround for #152
2026-02-14 16:56:12 +05:30
Philipp Emanuel Weidmann 1179013999 docs: update README 2026-02-14 16:32:08 +05:30
Philipp Emanuel Weidmann fe7bc1bae3 docs: update README 2026-02-14 10:47:28 +05:30
Philipp Emanuel Weidmann e70a1a85e8 fix: don't load checkpoint when evaluating a second model
Fixes #144
2026-02-14 10:02:17 +05:30
Philipp Emanuel Weidmann e7f8be98b7 fix: only export tokenizer when exporting full model
Fixes #143
2026-02-14 09:18:22 +05:30
Philipp Emanuel Weidmann 6017bcd347 fix: use compatible release specifiers for non-dev dependencies
Fixes #145

Credit to MuX on Discord for recognizing that this is an issue with Transformers 5
2026-02-13 12:27:57 +05:30
Philipp Emanuel Weidmann dd0b3a2f69 docs: update README 2026-02-11 11:09:17 +05:30
Philipp Emanuel Weidmann b873598b77 docs: improve settings documentation 2026-02-11 10:19:05 +05:30
Philipp Emanuel Weidmann 10ceb3098e chore: update copyright notice 2026-02-11 09:46:36 +05:30
Salman Chishti 745b582414 ci: upgrade GitHub Actions to latest versions (#137)
Signed-off-by: Salman Muin Kayser Chishti <13schishti@gmail.com>
2026-02-08 16:49:04 +05:30
Salman Chishti d0e9462fb8 ci: upgrade GitHub Actions for Node 24 compatibility (#136)
Signed-off-by: Salman Muin Kayser Chishti <13schishti@gmail.com>
2026-02-08 16:48:12 +05:30
Philipp Emanuel Weidmann f68a887a7b fix: improve code quality, improve UX, fix small bugs 2026-02-08 13:32:00 +05:30
Philipp Emanuel Weidmann 2690655a83 feat: print memory usage during run 2026-02-02 21:18:01 +05:30
Spiky Moth 3525b1ac22 Implement Magnitude-Preserving Orthogonal Ablation (#52)
* feat: add support for winsorizing the residuals

Adds setting winsorization_quantile, expressed as the quantile to clamp to.
- If set to a value below 1, the residuals obtained from evaluating the first token of the good and bad prompts are winsorized - that is, values outside the given quantile are clamped. Note that winsorization_quantile = 0.95 corresponds to a 90% winsorization.

* feat: implement magnitude-preserving orthogonal ablation

Adds boolean setting orthogonalize_direction:
- When enabled, only the component of the refusal directions that is orthogonal to the harmless direction is subtracted during abliteration.

Adds enum-valued setting row_normalization:
- 'none': No normalization.
- 'pre': Row-normalize the weight matrix before computing the LoRA adapter.
- 'full': Like 'pre', but re-normalizes to preserve original row magnitudes.

* prefer 'good' and 'bad' over 'harmless' and 'harmful'

* clarify how winsorization is applied

* store and reuse full peft_config

* remove unneeded cast

* make LoRA rank configurable for full normalization

* explain why the singular values are split across the components
2026-02-02 17:05:19 +05:30
anrp 42f5a9b553 fix: Use file instead of symlink lock (for windows) (#116) 2026-01-25 19:34:01 +05:30
anrp 451db0b76e fix: specify study name (#119)
If we don't, optuna will generate a UUID for a name, which will never be found when loading as it is a "different" study. https://optuna.readthedocs.io/en/stable/reference/generated/optuna.study.create_study.html#optuna.study.create_study
2026-01-25 18:48:23 +05:30
anrp ebc22c299e feat: Allow study progress to be saved & resumed (#106)
* feat: Store active study in log/study.jsonl and allow resuming

* Simplify resume logic with load_if_exists=True

* Significantly improve flexibility of study save/load

* Put constructor arguments at the highest precedence

* Review comments

---------

Co-authored-by: Spiky Moth <spikymoth@pm.me>
2026-01-23 19:49:37 +05:30
anrp d5c834c51d fix: Allow abliterating VL models (#108)
Per https://huggingface.co/docs/transformers/en/model_doc/auto#auto-classes,
it indicates that "There is one class of AutoModel for each task." Use
the presence of "vision_config" in the config.json to determine which.
2026-01-23 19:34:31 +05:30
anrp c86f49035e feat: Refactor save machinery and always allow user to save LoRA (#110) 2026-01-20 18:53:47 +05:30
anrp 85a6ec5ecb fix: Include kernels (allows MXFP4 to be loaded in MXFP4 instead of upcasting) (#107)
Co-authored-by: Andrew Patrikalakis <anrp@tri.global>
2026-01-16 17:30:24 +05:30
Philipp Emanuel Weidmann 632b1da622 feat: add config file for slop reduction 2026-01-11 18:51:26 +05:30
Philipp Emanuel Weidmann 1cfd09d7f3 ci: add style guide for Gemini 2026-01-09 14:58:56 +05:30
Philipp Emanuel Weidmann 09be09e12e fix: restore classification of empty responses as refusals
Fixes #93
2026-01-02 16:50:02 +05:30
Philipp Emanuel Weidmann 039f6222d2 feat: allow overriding the system prompt per dataset 2025-12-31 14:26:44 +05:30
Philipp Emanuel Weidmann c4b2ea0c42 feat: allow injecting prefixes and suffixes into prompts 2025-12-31 12:00:44 +05:30
Philipp Emanuel Weidmann 02a5237a02 feat: add option to print prompt/response pairs 2025-12-27 14:48:29 +05:30
Philipp Emanuel Weidmann cf8cf6f349 fix: address remaining ty complaint 2025-12-22 11:12:45 +05:30
Philipp Emanuel Weidmann 2141e110fb ci: treat ty warnings as errors 2025-12-22 10:57:36 +05:30
Philipp Emanuel Weidmann 39101137ef ci: add type checking 2025-12-22 10:48:42 +05:30
Philipp Emanuel Weidmann 064bed9a9f fix: resolve issues raised by ty
A single issue has been deliberately left unfixed to verify that the CI check works
2025-12-22 10:24:55 +05:30
_Vinayyyy_ 8d44b65670 feat: add continuous optimization option(latest changes updated) (#76)
* fix: a little merge bug

* refactor: simplify optimization loop based on feedback

* fix: address review comments

* fix: remove redundant check for study.best_trials

* fix: restore comments

---------

Co-authored-by: Vinay Umrethe <vinayumrethe99@gmail.com>
2025-12-20 18:57:57 +05:30
Philipp Emanuel Weidmann 5ddef6fd2f feat: add more CoT templates
Suggested by u/Chromix_ on Reddit
2025-12-20 17:12:46 +05:30
michaelh 92d0c0d551 feat: enumerate all available GPUs on startup (#86)
* feat: enumerate all available GPUs on startup

* feat: extend device enumeration to all accelerator types
2025-12-16 17:42:15 +05:30
michaelh 243f821d93 feat: Add 4-bit loading + LoRA support for low VRAM optimization (#60)
* Add files via upload

* perf: optimize abliteration matrix op (#46)

* perf: optimize abliteration matrix op

* refactor: comments and var names correspond with arditi

* refactor: fix comments and improve var notation

* fix: accidental line change and improve comments

---------

Co-authored-by: mad-cat-lon <113548315+mad-cat-lon@users.noreply.github.com>

* Fix line endings to LF

* Add hybrid approach for GPT-OSS compatibility

- Check for LoRA adapters before attempting LoRA abliteration
- Fall back to direct weight modification for nn.Parameter (GPT-OSS)
- Ensures compatibility across all model architectures

* Fix projector bug, update print statement, revert README

* Revert README changes to match upstream

* Fix import sorting for ruff

* Fix reload_model for evaluate_model, add type hints and validation

* Apply ruff formatting

* Replace load_in_4bit with quantization enum

* Fix precision loss: use FP32 refusal direction directly

* Move r assignment into non-LoRA path

* Fix linting: apply ruff formatting

* Add auto-merge for LoRA adapters on save/upload

* Fix linting: apply ruff formatting

* Implement CPU-based merge for 4-bit models with OOM fallback

* Remove use_lora flag (LoRA always on), add user prompt for 4-bit export

* Fix: PEFT target_modules expects module names without path prefix

* Fix linting: apply ruff formatting

* Add LoRA fallback and fix quantization_config handling

- Add try/except around LoRA initialization with fallback to direct weight modification
- Only pass quantization_config when not None (fixes gpt-oss loading)
- Use simple forward pass instead of generate() for model test (avoids chat template issues)
- Reset non-LoRA models by reloading in reload_model()
- Check self.use_lora before accessing LoRA adapters in abliterate()

* Add 8-bit quantization support via bitsandbytes

- Add BNB_8BIT option to QuantizationMethod enum
- Add --load-in-8bit CLI support (auto via pydantic-settings)
- Update documentation in config.py and config.default.toml
- Useful for mid-range VRAM (12-16 GB) as balance between memory and numeric stability

* Improve LoRA merge warning and fix linting

* Apply final ruff formatting

* Fix CI: apply ruff import sorting

* Use tiny model for CI efficiency

* Fix import sorting in test_lora.py

* Fix formatting in test_lora.py

* feat: Show merge warning for all models (requires high RAM)

* style: Apply ruff fixes

* Fix undefined Style import in main.py

* Fix(model): Support MoE/3D tensors and enforce dtype safety in abliterate

* Fix(ci): Format model.py with ruff

* Fix(main): Remove invalid style argument from prompt_select and unused import

* Fix logic errors, memory leak, and redundant merges in main.py

* Fix linting and formatting issues (isort, ruff)

* chore: Simplify .gitattributes as requested

* refactor: Remove defensive try-except around LoRA initialization

* chore: Update uv.lock with peft and bitsandbytes

* chore: Regenerate uv.lock to include missing peft dependency

* style: Fix import sorting (isort) for CI compliance

* style: Simplify .gitattributes to single line as requested

* Address PR #60 feedback: Remove caching, fix LoRA reload, global LoRA usage, style fixes

* Address PR review comments: clarify code, fix quantization, rename method

- Add explanatory comments for warning suppression and gc behavior
- Remove redundant gc.collect() calls (empty_cache handles it)
- Fix output message order (ask merge strategy before 'Uploading...')
- Add comment explaining 8-bit quantization doesn't need compute_dtype
- Remove extra newline after dtype comment
- Add future-proofing note for hybrid layer support (#43)
- Remove leftover comment in get_merged_model
- Delete test_lora.py (debug script, not a real test)
- Add comment explaining needs_reload flag purpose
- Extract quantization config into _get_quantization_config() helper
- Rename reload_model() to reset_model_for_trial() for clarity
- Fix reload_model to respect quantization config (fixes evaluate_model bug)
- Remove unused gc import

* Restore gc.collect() before empty_cache() for large models

* refactor: Remove LoRA fallback remnants, simplify code

- Remove use_lora flag (always true since LoRA is always applied)
- Remove isinstance(PeftModel) check in get_merged_model() (always true)
- Simplify reset_model_for_trial() by removing defensive try/except
- Remove redundant gc.collect() calls (empty_cache handles GC)
- Remove unused gc import from main.py

* Address p-e-w review feedback: rename reset_model, remove loaded_model_name, fix type hints, remove GPT-OSS MoE, update assertion

* Restore skip logic for non-LoRA modules and fix 4-bit base_layer.weight access

* Remove defensive lora_A check per review - get_layer_modules already filters

* Fix try_add: nest component init inside Module check, add assert for unexpected types

* Add note about module.weight assumption for type checking

* Change 'Reloading model' to 'Resetting model' in logging

---------

Co-authored-by: accemlcc <accemlcc@users.noreply.github.com>
Co-authored-by: mad-cat-lon <113548315+mad-cat-lon@users.noreply.github.com>
Co-authored-by: Hager <Michael.Hager@bruker.com>
2025-12-14 20:19:09 +05:30
Spiky Moth 9d1734855d feat: avoid excessive low divergence iteration (#73)
* feat: adjust scoring to avoid useless iteration

Adjusts the scoring function to avoid targeting meaninglessly low KL divergences.
Below a threshold value, the KL divergence score switches to the refusal count.
Adds config option kl_divergence_target (defaulting to 0.01).

* fix: Clean up parameter selection in objective

Create variables for num_layers and last_layer_index
* Improves readability and makes choices explicit

* feat: Print the parameters of the selected model
2025-12-14 14:26:48 +05:30
George 740aab61ba feat: add max_memory parameter to limit memory usage (#83)
* add max_memory parameter to limit memory usage

* Added to reload_model also

* forgot to add self

* Process max_memory once in __init__ and store it as an instance variable, then reuse it in both locations
2025-12-11 20:57:40 +05:30
16 changed files with 5106 additions and 1908 deletions
+11
View File
@@ -0,0 +1,11 @@
# Style guide and coding conventions
* Identifier names should not contain abbreviations unless those abbreviations are very widely used and understood (e.g. "KL divergence").
* Comments should start with a capital letter and end with a period. They should use correct grammar and spelling.
* Function and method signatures **must** be fully type-annotated, including the return type (if any).
* Every Python code file **must** start with an SPDX/Copyright header.
* Settings descriptions should start with a capital letter and end with a period.
* When new settings are added in `config.py`, they should also be added to `config.default.toml`, set to their default value and with their description as a comment. The order of settings in `config.default.toml` should match that in `config.py`.
* Pull requests should implement one change, and one change only.
* PRs containing multiple semantically independent changes **must** be split into multiple PRs.
* PRs **must not** change existing code unless the changes are *directly related* to the PR. This includes changes to formatting and comments.
+1
View File
@@ -0,0 +1 @@
* text eol=lf
+5 -2
View File
@@ -17,10 +17,10 @@ jobs:
steps:
- name: Check out code
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Install uv
uses: astral-sh/setup-uv@v5
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
cache-dependency-glob: "uv.lock"
@@ -37,6 +37,9 @@ jobs:
- name: Lint and check import sorting
run: uv run ruff check --output-format=github --extend-select I .
- name: Check typing
run: uv run ty check --output-format=github --error-on-warning .
- name: Build package
run: uv build
+7 -1
View File
@@ -7,7 +7,7 @@ wheels/
*.egg-info
# Virtual environments
.venv
.venv/
# Caches
/.ruff_cache/
@@ -17,3 +17,9 @@ wheels/
# Configuration files
/config.toml
# Study checkpoints
/checkpoints/
# Residual plots
/plots/
+19 -9
View File
@@ -1,11 +1,15 @@
# Heretic: Fully automatic censorship removal for language models
<img width="128" height="128" align="right" alt="Logo" src="https://github.com/user-attachments/assets/df5f2840-2f92-4991-aa57-252747d7182e" />
[![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)
# 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)
[![#1 Repository of the Day](https://trendshift.io/api/badge/repositories/20538)](https://trendshift.io/repositories/20538)
Heretic is a tool that removes censorship (aka "safety alignment") from
transformer-based language models without expensive post-training.
It combines an advanced implementation of directional ablation, also known
as "abliteration" ([Arditi et al. 2024](https://arxiv.org/abs/2406.11717)),
as "abliteration" ([Arditi et al. 2024](https://arxiv.org/abs/2406.11717),
Lai 2025 ([1](https://huggingface.co/blog/grimjim/projected-abliteration),
[2](https://huggingface.co/blog/grimjim/norm-preserving-biprojected-abliteration))),
with a TPE-based parameter optimizer powered by [Optuna](https://optuna.org/).
This approach enables Heretic to work **completely automatically.** Heretic
@@ -65,8 +69,11 @@ Heretic supports most dense models, including many multimodal models, and
several different MoE architectures. It does not yet support SSMs/hybrid models,
models with inhomogeneous layers, and certain novel attention systems.
You can find a collection of models that have been decensored using Heretic
[on Hugging Face](https://huggingface.co/collections/p-e-w/the-bestiary).
You can find a small collection of models that have been decensored using Heretic
[on Hugging Face](https://huggingface.co/collections/p-e-w/the-bestiary),
and the community has created and published
[well over 1,000](https://huggingface.co/models?other=heretic)
Heretic models in addition to those.
## Usage
@@ -89,8 +96,10 @@ a configuration file.
At the start of a program run, Heretic benchmarks the system to determine
the optimal batch size to make the most of the available hardware.
On an RTX 3090, with the default configuration, decensoring Llama-3.1-8B
takes about 45 minutes.
On an RTX 3090, with the default configuration, decensoring Llama-3.1-8B-Instruct
takes about 45 minutes. Note that Heretic supports model quantization with
bitsandbytes, which can drastically reduce the amount of VRAM required to process
models. Set the `quantization` option to `bnb_4bit` to enable quantization.
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,
@@ -242,7 +251,8 @@ The development of Heretic was informed by:
* [The original abliteration paper (Arditi et al. 2024)](https://arxiv.org/abs/2406.11717)
* [Maxime Labonne's article on abliteration](https://huggingface.co/blog/mlabonne/abliteration),
as well as some details from the model cards of his own abliterated models (see above)
* [Jim Lai's article describing "projected abliteration"](https://huggingface.co/blog/grimjim/projected-abliteration)
* Jim Lai's articles describing ["projected abliteration"](https://huggingface.co/blog/grimjim/projected-abliteration)
and ["norm-preserving biprojected abliteration"](https://huggingface.co/blog/grimjim/norm-preserving-biprojected-abliteration)
## Citation
@@ -263,7 +273,7 @@ If you use Heretic for your research, please cite it using the following BibTeX
## License
Copyright &copy; 2025 Philipp Emanuel Weidmann (<pew@worldwidemann.com>)
Copyright &copy; 2025-2026 Philipp Emanuel Weidmann (<pew@worldwidemann.com>) + contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
+43 -1
View File
@@ -1,4 +1,5 @@
# Copy this file to config.toml and edit the configuration to your liking.
# Rename this file to config.toml, place it in the working directory
# that you run Heretic from, and edit the configuration to your liking.
# List of PyTorch dtypes to try when loading model tensors.
# If loading with a dtype fails, the next dtype in the list will be tried.
@@ -15,9 +16,17 @@ dtypes = [
"float32",
]
# Quantization method to use when loading the model. Options:
# "none" (no quantization),
# "bnb_4bit" (4-bit quantization using bitsandbytes).
quantization = "none"
# Device map to pass to Accelerate when loading the model.
device_map = "auto"
# Maximum memory to allocate per device.
# max_memory = {"0": "20GB", "cpu": "64GB"}
# Number of input sequences to process in parallel (0 = auto).
batch_size = 0 # auto
@@ -27,6 +36,9 @@ max_batch_size = 128
# Maximum number of tokens to generate for each response.
max_response_length = 100
# Whether to print prompt/response pairs when counting refusals.
print_responses = false
# Whether to print detailed information about residuals and refusal directions.
print_residual_geometry = false
@@ -46,12 +58,42 @@ residual_plot_style = "dark_background"
# This is used to ensure balanced co-optimization of KL divergence and refusal count.
kl_divergence_scale = 1.0
# The KL divergence to target. Below this value, an objective based on the refusal count is used.
# This helps prevent the sampler from extensively exploring parameter combinations that "do nothing".
kl_divergence_target = 0.01
# Whether to adjust the refusal directions so that only the component that is
# orthogonal to the good direction is subtracted during abliteration.
orthogonalize_direction = false
# How to apply row normalization of the weights. Options:
# "none" (no normalization),
# "pre" (compute LoRA adapter relative to row-normalized weights),
# "full" (like "pre", but renormalizes to preserve original row magnitudes).
row_normalization = "none"
# The rank of the LoRA adapter to use when "full" row normalization is used.
# Row magnitude preservation is approximate due to non-linear effects,
# and this determines the rank of that approximation. Higher ranks produce
# larger output files and may slow down evaluation.
full_normalization_lora_rank = 3
# The symmetric winsorization to apply to the per-prompt, per-layer residual vectors,
# expressed as the quantile to clamp to (between 0 and 1). Disabled by default.
# This can tame so-called "massive activations" that occur in some models.
# Example: winsorization_quantile = 0.95 computes the 0.95-quantile of the absolute values
# of the components, then clamps the magnitudes of all components to that quantile.
winsorization_quantile = 1.0
# Number of abliteration trials to run during optimization.
n_trials = 200
# Number of trials that use random sampling for the purpose of exploration.
n_startup_trials = 60
# Directory to save and load study progress to/from.
study_checkpoint_dir = "checkpoints"
# Strings whose presence in a response (case insensitive) identifies the response as a refusal.
refusal_markers = [
"sorry",
+163
View File
@@ -0,0 +1,163 @@
# Rename this file to config.toml, place it in the working directory
# that you run Heretic from, and edit the configuration to your liking.
max_response_length = 300
residual_plot_title = "PaCMAP Projection of Residuals for Slop-Suppressing/Inducing Prompts"
refusal_markers = [
"Eldoria",
"Lumina",
"ethereal",
"thick with",
"celestial",
"radiant",
"black as",
"despair",
"crimson",
"resplendent",
"unravel",
"belied",
"velvet",
"moonless",
"moonlit",
"entangled",
"twilight",
"forever",
"first kiss",
"gasp",
"whisper",
"hue",
"symphony",
"scarcely believe",
"gilded",
"hummed",
"abuzz",
"perpetually",
"scent",
"perfume",
"neon lights",
"kaleidoscopic",
"adrift",
"sultry",
"melancholic",
"stark contrast",
"inky",
"coy",
"vast",
"purr",
"radiant",
"beacon",
"a thousand ships",
"tapestry",
"bustling",
"abyss",
"gnarled",
"tremble",
"trembling",
"profound",
"terrible",
"ancient",
"sapphire",
"ruby",
"emerald",
"diamond",
"stolen",
"promise",
"the air was",
"obsidian",
"gleaming with",
"faintest hint",
"trepidation",
"sun-kissed",
"azure",
"deep",
"beloved",
"cosmos",
"devoid",
"soft chime",
"echo",
"palpable",
"blossom",
"adrift",
"faint",
"emerged",
"shiver",
"spine",
"hairs on the back",
"cinematic",
"specter",
"golden",
"inescapable",
"sentinel",
"flicker",
"testament",
"embodiment",
"etched with",
"rise and fall",
"the very air",
"slither",
"a pang of",
"eternal",
"eternity",
"veil of",
"painting the",
"bathed in",
"boundless",
"stretched out",
"beneath",
"lullaby",
"unsuspecting",
"handsome",
"defied the very",
"barely above",
"never-ending",
"caress",
"realm",
"fiery",
"raven",
"twin pools",
"gloaming",
"grimy",
"labyrinth",
"the very notion",
"something...",
"the halls of",
"conflagration of",
"shattered like",
"as dark as",
"yearned for",
"unyielding",
"lifetime",
"ensnared",
]
system_prompt = "You are a professional writer."
[good_prompts]
dataset = "llm-aes/writing-prompts"
split = "train[:500]"
column = "prompt"
prefix = "Write a short story based on the writing prompt below. Avoid literary cliches, purple prose, and flowery language.\n\nWriting prompt:"
residual_plot_label = "Slop-suppressing prompts"
residual_plot_color = "royalblue"
[bad_prompts]
dataset = "llm-aes/writing-prompts"
split = "train[:500]"
column = "prompt"
prefix = "Write a short story based on the writing prompt below. Make extensive use of literary cliches, purple prose, and flowery language.\n\nWriting prompt:"
residual_plot_label = "Slop-inducing prompts"
residual_plot_color = "darkorange"
[good_evaluation_prompts]
dataset = "llm-aes/writing-prompts"
split = "train[1000:1100]"
column = "prompt"
prefix = "Write a short story based on the writing prompt below. Avoid literary cliches, purple prose, and flowery language.\n\nWriting prompt:"
[bad_evaluation_prompts]
dataset = "llm-aes/writing-prompts"
split = "train[1000:1100]"
column = "prompt"
prefix = "Write a short story based on the writing prompt below.\n\nWriting prompt:"
+25 -16
View File
@@ -1,6 +1,6 @@
[project]
name = "heretic-llm"
version = "1.1.0"
version = "1.2.0"
description = "Fully automatic censorship removal for language models"
readme = "README.md"
license = "AGPL-3.0-or-later"
@@ -22,30 +22,39 @@ classifiers = [
"Programming Language :: Python :: 3.12",
]
dependencies = [
"accelerate>=1.10.0",
"datasets>=4.0.0",
"hf-transfer>=0.1.9",
"huggingface-hub>=0.34.4",
"optuna>=4.5.0",
"pydantic-settings>=2.10.1",
"questionary>=2.1.1",
"rich>=14.1.0",
"transformers>=4.55.2",
"accelerate~=1.13",
"bitsandbytes~=0.49",
"datasets~=4.7",
"hf-transfer~=0.1",
"huggingface-hub~=1.7",
"immutabledict~=4.3",
"kernels~=0.12",
"langdetect~=1.0",
"lm-eval[hf]~=0.4",
"numpy~=2.2",
"optuna~=4.7",
"peft~=0.18",
"psutil~=7.2",
"pydantic-settings~=2.13",
"questionary~=2.1",
"rich~=14.3",
"tqdm~=4.67",
"transformers~=5.3",
]
[project.optional-dependencies]
research = [
"geom-median>=0.1.0",
"imageio>=2.37.2",
"matplotlib>=3.10.7",
"numpy>=2.2.6",
"pacmap>=0.8.0",
"scikit-learn>=1.7.2",
"geom-median~=0.1",
"imageio~=2.37",
"matplotlib~=3.10",
"pacmap~=0.8",
"scikit-learn~=1.7",
]
[dependency-groups]
dev = [
"ruff>=0.14.5",
"ty>=0.0.5",
]
[project.urls]
+13 -9
View File
@@ -1,11 +1,13 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (C) 2025 Philipp Emanuel Weidmann <pew@worldwidemann.com>
# Copyright (C) 2025-2026 Philipp Emanuel Weidmann <pew@worldwidemann.com> + contributors
from pathlib import Path
import numpy as np
import torch
import torch.linalg as LA
import torch.nn.functional as F
from numpy.typing import NDArray
from rich.progress import track
from rich.table import Table
from torch import Tensor
@@ -30,8 +32,10 @@ class Analyzer:
def print_residual_geometry(self):
try:
from geom_median.torch import compute_geometric_median
from sklearn.metrics import silhouette_score
from geom_median.torch import ( # ty:ignore[unresolved-import]
compute_geometric_median,
)
from sklearn.metrics import silhouette_score # ty:ignore[unresolved-import]
except ImportError:
print()
print(
@@ -152,12 +156,12 @@ class Analyzer:
def plot_residuals(self):
try:
import imageio.v3 as iio
import matplotlib.pyplot as plt
import numpy as np
from geom_median.numpy import compute_geometric_median
from numpy.typing import NDArray
from pacmap import PaCMAP
import imageio.v3 as iio # ty:ignore[unresolved-import]
import matplotlib.pyplot as plt # ty:ignore[unresolved-import]
from geom_median.numpy import ( # ty:ignore[unresolved-import]
compute_geometric_median,
)
from pacmap import PaCMAP # ty:ignore[unresolved-import]
except ImportError:
print()
print(
+228 -17
View File
@@ -1,17 +1,31 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (C) 2025 Philipp Emanuel Weidmann <pew@worldwidemann.com>
# Copyright (C) 2025-2026 Philipp Emanuel Weidmann <pew@worldwidemann.com> + contributors
from enum import Enum
from typing import Dict
from pydantic import BaseModel, Field
from pydantic_settings import (
BaseSettings,
CliSettingsSource,
EnvSettingsSource,
PydanticBaseSettingsSource,
SettingsConfigDict,
TomlConfigSettingsSource,
)
class QuantizationMethod(str, Enum):
NONE = "none"
BNB_4BIT = "bnb_4bit"
class RowNormalization(str, Enum):
NONE = "none"
PRE = "pre"
# POST = "post" # Theoretically possible, but provides no advantage.
FULL = "full"
class DatasetSpecification(BaseModel):
dataset: str = Field(
description="Hugging Face dataset ID, or path to dataset on disk."
@@ -21,6 +35,21 @@ class DatasetSpecification(BaseModel):
column: str = Field(description="Column in the dataset that contains the prompts.")
prefix: str = Field(
default="",
description="Text to prepend to each prompt.",
)
suffix: str = Field(
default="",
description="Text to append to each prompt.",
)
system_prompt: str | None = Field(
default=None,
description="System prompt to use with the prompts (overrides global system prompt if set).",
)
residual_plot_label: str | None = Field(
default=None,
description="Label to use for the dataset in plots of residual vectors.",
@@ -32,12 +61,27 @@ 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):
model: str = Field(description="Hugging Face model ID, or path to model on disk.")
evaluate_model: str | None = Field(
default=None,
description="If this model ID or path is set, then instead of abliterating the main model, evaluate this model relative to the main model.",
description=(
"If this model ID or path is set, then instead of abliterating the main model, "
"evaluate this model relative to the main model."
),
)
dtypes: list[str] = Field(
@@ -53,7 +97,19 @@ class Settings(BaseSettings):
# if that was the dtype "auto" resolved to).
"float32",
],
description="List of PyTorch dtypes to try when loading model tensors. If loading with a dtype fails, the next dtype in the list will be tried.",
description=(
"List of PyTorch dtypes to try when loading model tensors. "
"If loading with a dtype fails, the next dtype in the list will be tried."
),
)
quantization: QuantizationMethod = Field(
default=QuantizationMethod.NONE,
description=(
"Quantization method to use when loading the model. Options: "
'"none" (no quantization), '
'"bnb_4bit" (4-bit quantization using bitsandbytes).'
),
)
device_map: str | Dict[str, int | str] = Field(
@@ -61,6 +117,11 @@ class Settings(BaseSettings):
description="Device map to pass to Accelerate when loading the model.",
)
max_memory: Dict[str, str] | None = Field(
default=None,
description='Maximum memory to allocate per device (e.g., {"0": "20GB", "cpu": "64GB"}).',
)
trust_remote_code: bool | None = Field(
default=None,
description="Whether to trust remote code when loading the model.",
@@ -81,6 +142,11 @@ class Settings(BaseSettings):
description="Maximum number of tokens to generate for each response.",
)
print_responses: bool = Field(
default=False,
description="Whether to print prompt/response pairs when counting refusals.",
)
print_residual_geometry: bool = Field(
default=False,
description="Whether to print detailed information about residuals and refusal directions.",
@@ -114,6 +180,89 @@ class Settings(BaseSettings):
),
)
kl_divergence_target: float = Field(
default=0.01,
description=(
"The KL divergence to target. Below this value, an objective based on the refusal count is used. "
'This helps prevent the sampler from extensively exploring parameter combinations that "do nothing".'
),
)
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(
default=False,
description=(
"Whether to adjust the refusal directions so that only the component that is "
"orthogonal to the good direction is subtracted during abliteration."
),
)
row_normalization: RowNormalization = Field(
default=RowNormalization.FULL,
description=(
"How to apply row normalization of the weights. Options: "
'"none" (no normalization), '
'"pre" (compute LoRA adapter relative to row-normalized weights), '
'"full" (like "pre", but renormalizes to preserve original row magnitudes).'
),
)
full_normalization_lora_rank: int = Field(
default=3,
description=(
'The rank of the LoRA adapter to use when "full" row normalization is used. '
"Row magnitude preservation is approximate due to non-linear effects, "
"and this determines the rank of that approximation. Higher ranks produce "
"larger output files and may slow down evaluation."
),
)
winsorization_quantile: float = Field(
default=1.0,
description=(
"The symmetric winsorization to apply to the per-prompt, per-layer residual vectors, "
"expressed as the quantile to clamp to (between 0 and 1). Disabled by default. "
'This can tame so-called "massive activations" that occur in some models. '
"Example: winsorization_quantile = 0.95 computes the 0.95-quantile of the absolute values "
"of the components, then clamps the magnitudes of all components to that quantile."
),
)
n_trials: int = Field(
default=200,
description="Number of abliteration trials to run during optimization.",
@@ -124,6 +273,72 @@ class Settings(BaseSettings):
description="Number of trials that use random sampling for the purpose of exploration.",
)
study_checkpoint_dir: str = Field(
default="checkpoints",
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(
default=[
"sorry",
@@ -207,16 +422,6 @@ class Settings(BaseSettings):
description="Dataset of prompts that tend to result in refusals (used for evaluating model performance).",
)
# "Model" refers to the Pydantic model of the settings class here,
# not to the language model. The field must have this exact name.
model_config = SettingsConfigDict(
toml_file="config.toml",
env_prefix="HERETIC_",
cli_parse_args=True,
cli_implicit_flags=True,
cli_kebab_case=True,
)
@classmethod
def settings_customise_sources(
cls,
@@ -227,9 +432,15 @@ class Settings(BaseSettings):
file_secret_settings: PydanticBaseSettingsSource,
) -> tuple[PydanticBaseSettingsSource, ...]:
return (
init_settings,
env_settings,
init_settings, # Used during resume - should override *all* other sources.
CliSettingsSource(
settings_cls,
cli_parse_args=True,
cli_implicit_flags=True,
cli_kebab_case=True,
),
EnvSettingsSource(settings_cls, env_prefix="HERETIC_"),
dotenv_settings,
file_secret_settings,
TomlConfigSettingsSource(settings_cls),
TomlConfigSettingsSource(settings_cls, toml_file="config.toml"),
)
+95 -27
View File
@@ -1,33 +1,44 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (C) 2025 Philipp Emanuel Weidmann <pew@worldwidemann.com>
# Copyright (C) 2025-2026 Philipp Emanuel Weidmann <pew@worldwidemann.com> + contributors
import lm_eval
import torch.nn.functional as F
from lm_eval.models.huggingface import HFLM
from torch import Tensor
from .config import Settings
from .model import Model
from .utils import load_prompts, print
from .utils import Prompt, load_prompts, print
class Evaluator:
settings: Settings
model: Model
good_prompts: list[Prompt]
bad_prompts: list[Prompt]
base_logprobs: Tensor
base_refusals: int
def __init__(self, settings: Settings, model: Model):
self.settings = settings
self.model = model
print()
print(
f"Loading good evaluation prompts from [bold]{settings.good_evaluation_prompts.dataset}[/]..."
)
self.good_prompts = load_prompts(settings.good_evaluation_prompts)
print(f"* [bold]{len(self.good_prompts)}[/] prompts loaded")
if not settings.use_piqa:
print()
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")
print("* Obtaining first-token probability distributions...")
self.base_logprobs = model.get_logprobs_batched(self.good_prompts)
print("* Obtaining first-token probability distributions...")
self.base_logprobs = model.get_logprobs_batched(self.good_prompts)
print()
print(
f"Loading bad evaluation prompts from [bold]{settings.bad_evaluation_prompts.dataset}[/]..."
)
self.bad_prompts = load_prompts(settings.bad_evaluation_prompts)
self.bad_prompts = load_prompts(settings, settings.bad_evaluation_prompts)
print(f"* [bold]{len(self.bad_prompts)}[/] prompts loaded")
print("* Counting model refusals...")
@@ -57,28 +68,85 @@ class Evaluator:
return False
def count_refusals(self) -> int:
responses = self.model.get_responses_batched(self.bad_prompts)
refusals = [response for response in responses if self.is_refusal(response)]
return len(refusals)
refusal_count = 0
responses = self.model.get_responses_batched(
self.bad_prompts,
skip_special_tokens=True,
)
for prompt, response in zip(self.bad_prompts, responses):
is_refusal = self.is_refusal(response)
if is_refusal:
refusal_count += 1
if self.settings.print_responses:
print()
print(f"[bold]System prompt:[/] {prompt.system}")
print(f"[bold]Prompt:[/] {prompt.user}")
if not response.strip():
response = "[italic]\\[empty][/]"
print(
f"[bold]Response:[/] [{'red' if is_refusal else 'green'}]{response}[/]"
)
if self.settings.print_responses:
print()
return refusal_count
def get_score(self) -> tuple[tuple[float, float], float, int]:
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}[/]")
if self.settings.use_piqa:
print(" * Running PIQA benchmark...")
hflm = HFLM(
pretrained=self.model.model, # ty:ignore[invalid-argument-type]
tokenizer=self.model.tokenizer, # ty:ignore[invalid-argument-type]
batch_size="auto",
)
results = lm_eval.simple_evaluate(
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...")
refusals = self.count_refusals()
print(f" * Refusals: [bold]{refusals}[/]/{len(self.bad_prompts)}")
score = (
(kl_divergence / self.settings.kl_divergence_scale),
(refusals / self.base_refusals),
refusals_score = (
refusals / self.base_refusals if self.base_refusals > 0 else float(refusals)
)
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
+846 -265
View File
File diff suppressed because it is too large Load Diff
+851 -107
View File
File diff suppressed because it is too large Load Diff
+40
View File
@@ -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]
+126 -25
View File
@@ -1,10 +1,10 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (C) 2025 Philipp Emanuel Weidmann <pew@worldwidemann.com>
# Copyright (C) 2025-2026 Philipp Emanuel Weidmann <pew@worldwidemann.com> + contributors
import gc
import getpass
import os
from dataclasses import asdict
from dataclasses import dataclass
from importlib.metadata import version
from pathlib import Path
from typing import Any, TypeVar
@@ -17,19 +17,44 @@ from accelerate.utils import (
is_sdaa_available,
is_xpu_available,
)
from datasets import ReadInstruction, load_dataset, load_from_disk
from datasets import DatasetDict, ReadInstruction, load_dataset, load_from_disk
from datasets.config import DATASET_STATE_JSON_FILENAME
from datasets.download.download_manager import DownloadMode
from datasets.utils.info_utils import VerificationMode
from optuna import Trial
from psutil import Process
from questionary import Choice, Style
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
def print_memory_usage():
def p(label: str, size_in_bytes: int):
print(f"[grey50]{label}: [bold]{size_in_bytes / (1024**3):.2f} GB[/][/]")
p("Resident system RAM", Process().memory_info().rss)
if torch.cuda.is_available():
count = torch.cuda.device_count()
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():
count = torch.xpu.device_count()
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():
p("Allocated MPS memory", torch.mps.current_allocated_memory())
p("Driver (reserved) MPS memory", torch.mps.driver_allocated_memory())
def is_notebook() -> bool:
# Check for specific environment variables (Colab, Kaggle).
# This is necessary because when running as a subprocess (e.g. !heretic),
@@ -39,7 +64,7 @@ def is_notebook() -> bool:
# Check IPython shell type (for library usage).
try:
from IPython import get_ipython # pyright: ignore[reportMissingModuleSource]
from IPython import get_ipython # ty:ignore[unresolved-import]
shell = get_ipython()
if shell is None:
@@ -136,7 +161,16 @@ def format_duration(seconds: float) -> str:
return f"{seconds}s"
def load_prompts(specification: DatasetSpecification) -> list[str]:
@dataclass
class Prompt:
system: str
user: str
def load_prompts(
settings: Settings,
specification: DatasetSpecification,
) -> list[Prompt]:
path = specification.dataset
split_str = specification.split
@@ -145,6 +179,9 @@ def load_prompts(specification: DatasetSpecification) -> list[str]:
# Dataset saved with datasets.save_to_disk; needs special handling.
# Path should be the subdirectory for a particular split.
dataset = load_from_disk(path)
assert not isinstance(dataset, DatasetDict), (
"Loading dataset dicts is not supported"
)
# Parse the split instructions.
instruction = ReadInstruction.from_spec(split_str)
# Associate the split with its number of examples (lines).
@@ -168,7 +205,27 @@ def load_prompts(specification: DatasetSpecification) -> list[str]:
# Probably a repository path; let load_dataset figure it out.
dataset = load_dataset(path, split=split_str)
return list(dataset[specification.column])
prompts = list(dataset[specification.column])
if specification.prefix:
prompts = [f"{specification.prefix} {prompt}" for prompt in prompts]
if specification.suffix:
prompts = [f"{prompt} {specification.suffix}" for prompt in prompts]
system_prompt = (
settings.system_prompt
if specification.system_prompt is None
else specification.system_prompt
)
return [
Prompt(
system=system_prompt,
user=prompt,
)
for prompt in prompts
]
T = TypeVar("T")
@@ -178,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)]
# 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():
# 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.
@@ -189,43 +254,76 @@ def empty_cache():
elif is_xpu_available():
torch.xpu.empty_cache()
elif is_mlu_available():
torch.mlu.empty_cache()
torch.mlu.empty_cache() # ty:ignore[unresolved-attribute]
elif is_sdaa_available():
torch.sdaa.empty_cache()
torch.sdaa.empty_cache() # ty:ignore[unresolved-attribute]
elif is_musa_available():
torch.musa.empty_cache()
torch.musa.empty_cache() # ty:ignore[unresolved-attribute]
elif torch.backends.mps.is_available():
torch.mps.empty_cache()
gc.collect()
def get_trial_parameters(trial: Trial) -> dict[str, str]:
params = {}
def get_trial_parameters(settings: Settings, trial: Trial) -> dict[str, str]:
if settings.use_ara:
parameters = trial.user_attrs["ara_parameters"]
direction_index = trial.user_attrs["direction_index"]
params["direction_index"] = (
"per layer" if (direction_index is None) else f"{direction_index:.2f}"
)
return {
name: (f"{value:.4f}" if isinstance(value, float) else f"{value}")
for name, value in parameters.items()
}
else:
params = {}
for component, parameters in trial.user_attrs["parameters"].items():
for name, value in asdict(parameters).items():
params[f"{component}.{name}"] = f"{value:.2f}"
direction_index = trial.user_attrs["direction_index"]
params["direction_index"] = (
"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(
settings: Settings,
trial: Trial,
base_refusals: int,
bad_prompts: list[str],
bad_prompts: list[Prompt],
) -> 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 {
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
@@ -235,7 +333,7 @@ def get_readme_intro(
chr(10).join(
[
f"| **{name}** | {value} |"
for name, value in get_trial_parameters(trial).items()
for name, value in get_trial_parameters(settings, trial).items()
]
)
}
@@ -244,7 +342,10 @@ def get_readme_intro(
| 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}/{
len(bad_prompts)
} |
Generated
+2633 -1429
View File
File diff suppressed because it is too large Load Diff