Instructions to use yass4/halt-cot with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use yass4/halt-cot with Transformers:
# Load model directly from transformers import AutoModel model = AutoModel.from_pretrained("yass4/halt-cot", dtype="auto", device_map="auto") - Notebooks
- Google Colab
- Kaggle
Publish HALT-CoT
Browse files- .gitignore +17 -0
- CITATION.cff +8 -0
- LICENSE +21 -0
- README.md +136 -0
- app.py +125 -0
- examples/run_halt_cot.py +19 -0
- halt_cot/__init__.py +39 -0
- halt_cot/cli.py +118 -0
- halt_cot/core.py +294 -0
- halt_cot/transformers_backend.py +291 -0
- pyproject.toml +54 -0
- requirements.txt +5 -0
- scripts/publish_to_hf.py +63 -0
- tests/test_core.py +92 -0
.gitignore
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.py[cod]
|
| 3 |
+
.pytest_cache/
|
| 4 |
+
.ruff_cache/
|
| 5 |
+
.mypy_cache/
|
| 6 |
+
.venv/
|
| 7 |
+
venv/
|
| 8 |
+
env/
|
| 9 |
+
build/
|
| 10 |
+
dist/
|
| 11 |
+
*.egg-info/
|
| 12 |
+
.env
|
| 13 |
+
.env.*
|
| 14 |
+
!.env.example
|
| 15 |
+
.DS_Store
|
| 16 |
+
logs/
|
| 17 |
+
outputs/
|
CITATION.cff
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
cff-version: 1.2.0
|
| 2 |
+
message: "If you use HALT-CoT, please cite the paper."
|
| 3 |
+
title: "HALT-CoT: Model-Agnostic Early Stopping for Chain-of-Thought Reasoning via Answer Entropy"
|
| 4 |
+
authors:
|
| 5 |
+
- family-names: "Laaouach"
|
| 6 |
+
given-names: "Yassir"
|
| 7 |
+
year: 2026
|
| 8 |
+
license: "MIT"
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 Yassir Laaouach
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
README.md
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: HALT-CoT
|
| 3 |
+
sdk: gradio
|
| 4 |
+
app_file: app.py
|
| 5 |
+
license: mit
|
| 6 |
+
tags:
|
| 7 |
+
- chain-of-thought
|
| 8 |
+
- reasoning
|
| 9 |
+
- early-stopping
|
| 10 |
+
- entropy
|
| 11 |
+
- transformers
|
| 12 |
+
- inference-optimization
|
| 13 |
+
---
|
| 14 |
+
|
| 15 |
+
# HALT-CoT
|
| 16 |
+
|
| 17 |
+
HALT-CoT is an inference-time early stopping method for chain-of-thought reasoning. After each generated reasoning step, it computes the Shannon entropy of the model's answer distribution over candidate answers. When entropy stays below a threshold, generation stops and the current most likely answer is returned.
|
| 18 |
+
|
| 19 |
+
This repository packages the method from:
|
| 20 |
+
|
| 21 |
+
**HALT-CoT: Model-Agnostic Early Stopping for Chain-of-Thought Reasoning via Answer Entropy**
|
| 22 |
+
Yassir Laaouach
|
| 23 |
+
|
| 24 |
+
The release is a method implementation and Hugging Face Space, not trained model weights.
|
| 25 |
+
|
| 26 |
+
## What Is Included
|
| 27 |
+
|
| 28 |
+
- Dependency-free core entropy and halting controller in `halt_cot/core.py`.
|
| 29 |
+
- Hugging Face Transformers backend in `halt_cot/transformers_backend.py`.
|
| 30 |
+
- CLI entrypoint: `halt-cot`.
|
| 31 |
+
- Gradio Space app in `app.py`.
|
| 32 |
+
- Publish helper for `huggingface_hub`.
|
| 33 |
+
- Focused tests for entropy, candidate sets, and halting behavior.
|
| 34 |
+
|
| 35 |
+
## Install
|
| 36 |
+
|
| 37 |
+
```bash
|
| 38 |
+
pip install -e ".[transformers]"
|
| 39 |
+
```
|
| 40 |
+
|
| 41 |
+
For the Gradio Space locally:
|
| 42 |
+
|
| 43 |
+
```bash
|
| 44 |
+
pip install -e ".[demo]"
|
| 45 |
+
python app.py
|
| 46 |
+
```
|
| 47 |
+
|
| 48 |
+
## CLI Example
|
| 49 |
+
|
| 50 |
+
```bash
|
| 51 |
+
python -m halt_cot.cli \
|
| 52 |
+
--model Qwen/Qwen2.5-0.5B-Instruct \
|
| 53 |
+
--question "If a shop has 12 apples and sells 5, how many apples are left?" \
|
| 54 |
+
--candidates 5 6 7 8 9 \
|
| 55 |
+
--theta 0.6 \
|
| 56 |
+
--consecutive 2
|
| 57 |
+
```
|
| 58 |
+
|
| 59 |
+
## Python Example
|
| 60 |
+
|
| 61 |
+
```python
|
| 62 |
+
from halt_cot import HaltCoTConfig
|
| 63 |
+
from halt_cot.transformers_backend import HaltCoTForCausalLM
|
| 64 |
+
|
| 65 |
+
runner = HaltCoTForCausalLM.from_pretrained("Qwen/Qwen2.5-0.5B-Instruct")
|
| 66 |
+
config = HaltCoTConfig(theta=0.6, consecutive_low_entropy=2, max_steps=8)
|
| 67 |
+
|
| 68 |
+
result = runner.run(
|
| 69 |
+
"If a shop has 12 apples and sells 5, how many apples are left?",
|
| 70 |
+
candidates=["5", "6", "7", "8", "9"],
|
| 71 |
+
config=config,
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
print(result.answer)
|
| 75 |
+
print(result.reasoning)
|
| 76 |
+
```
|
| 77 |
+
|
| 78 |
+
## Method
|
| 79 |
+
|
| 80 |
+
At reasoning step `i`, HALT-CoT scores candidate answers `a in A` from the next-token logits after the current partial chain. It normalizes those candidate scores into `p_i(a)` and computes:
|
| 81 |
+
|
| 82 |
+
```text
|
| 83 |
+
H_i = - sum_a p_i(a) log p_i(a)
|
| 84 |
+
```
|
| 85 |
+
|
| 86 |
+
The controller halts when `H_i < theta`, optionally requiring the condition for multiple consecutive steps. This implementation uses bits by default, which aligns with the entropy traces in the paper. If you tune thresholds in nats, set `entropy_unit="nats"`.
|
| 87 |
+
|
| 88 |
+
Suggested starting thresholds from the paper:
|
| 89 |
+
|
| 90 |
+
- Math-style numeric tasks: `theta` in `[0.5, 0.7]`.
|
| 91 |
+
- StrategyQA-style yes/no tasks: `theta = 0.8`.
|
| 92 |
+
- CommonsenseQA-style multiple choice: `theta = 0.7`.
|
| 93 |
+
|
| 94 |
+
For numeric tasks, use task-specific candidate answers where possible. The helper `numeric_candidates_from_texts` can build candidates from training answers plus an integer range.
|
| 95 |
+
|
| 96 |
+
## Publish To Hugging Face
|
| 97 |
+
|
| 98 |
+
Log in once:
|
| 99 |
+
|
| 100 |
+
```bash
|
| 101 |
+
python -m huggingface_hub.commands.huggingface_cli login
|
| 102 |
+
```
|
| 103 |
+
|
| 104 |
+
### Free Code/Method Repository
|
| 105 |
+
|
| 106 |
+
Hugging Face may require a PRO subscription for hosted Gradio Spaces on `cpu-basic`. To publish the method, code, docs, and tests for free, upload this folder as a normal Hugging Face model repository:
|
| 107 |
+
|
| 108 |
+
```bash
|
| 109 |
+
python scripts/publish_to_hf.py --repo-id <your-username>/halt-cot --repo-type model
|
| 110 |
+
```
|
| 111 |
+
|
| 112 |
+
For this account, the repo id is:
|
| 113 |
+
|
| 114 |
+
```bash
|
| 115 |
+
python scripts/publish_to_hf.py --repo-id yass4/halt-cot --repo-type model
|
| 116 |
+
```
|
| 117 |
+
|
| 118 |
+
### Interactive Gradio Space
|
| 119 |
+
|
| 120 |
+
If your account can create Gradio Spaces, upload the same folder as a Space:
|
| 121 |
+
|
| 122 |
+
```bash
|
| 123 |
+
python scripts/publish_to_hf.py --repo-id <your-username>/halt-cot --repo-type space
|
| 124 |
+
```
|
| 125 |
+
|
| 126 |
+
The default Space model is controlled by `HALT_COT_MODEL_ID`. Set it in the Space variables to use a different causal LM. Use `HALT_COT_DEVICE_MAP=auto` on hardware that supports Accelerate device placement.
|
| 127 |
+
|
| 128 |
+
## Citation
|
| 129 |
+
|
| 130 |
+
```bibtex
|
| 131 |
+
@misc{laaouach2026haltcot,
|
| 132 |
+
title = {HALT-CoT: Model-Agnostic Early Stopping for Chain-of-Thought Reasoning via Answer Entropy},
|
| 133 |
+
author = {Laaouach, Yassir},
|
| 134 |
+
year = {2026}
|
| 135 |
+
}
|
| 136 |
+
```
|
app.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Hugging Face Space app for HALT-CoT."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
|
| 7 |
+
import gradio as gr
|
| 8 |
+
|
| 9 |
+
from halt_cot import HaltCoTConfig
|
| 10 |
+
from halt_cot.transformers_backend import HaltCoTForCausalLM
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
DEFAULT_MODEL_ID = os.getenv("HALT_COT_MODEL_ID", "Qwen/Qwen2.5-0.5B-Instruct")
|
| 14 |
+
DEFAULT_DEVICE_MAP = os.getenv("HALT_COT_DEVICE_MAP") or None
|
| 15 |
+
DEFAULT_THETA = float(os.getenv("HALT_COT_THETA", "0.6"))
|
| 16 |
+
|
| 17 |
+
_RUNNER: HaltCoTForCausalLM | None = None
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def get_runner() -> HaltCoTForCausalLM:
|
| 21 |
+
global _RUNNER
|
| 22 |
+
if _RUNNER is None:
|
| 23 |
+
_RUNNER = HaltCoTForCausalLM.from_pretrained(
|
| 24 |
+
DEFAULT_MODEL_ID,
|
| 25 |
+
device_map=DEFAULT_DEVICE_MAP,
|
| 26 |
+
)
|
| 27 |
+
return _RUNNER
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def parse_candidates(candidate_text: str) -> list[str]:
|
| 31 |
+
lines = []
|
| 32 |
+
for raw_line in candidate_text.replace(",", "\n").splitlines():
|
| 33 |
+
line = raw_line.strip()
|
| 34 |
+
if line:
|
| 35 |
+
lines.append(line)
|
| 36 |
+
if len(lines) < 2:
|
| 37 |
+
raise gr.Error("Provide at least two candidate answers.")
|
| 38 |
+
return lines
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def run_halt_cot(
|
| 42 |
+
question: str,
|
| 43 |
+
candidate_text: str,
|
| 44 |
+
theta: float,
|
| 45 |
+
consecutive: int,
|
| 46 |
+
max_steps: int,
|
| 47 |
+
step_max_new_tokens: int,
|
| 48 |
+
):
|
| 49 |
+
if not question.strip():
|
| 50 |
+
raise gr.Error("Question is required.")
|
| 51 |
+
|
| 52 |
+
config = HaltCoTConfig(
|
| 53 |
+
theta=float(theta),
|
| 54 |
+
consecutive_low_entropy=int(consecutive),
|
| 55 |
+
max_steps=int(max_steps),
|
| 56 |
+
step_max_new_tokens=int(step_max_new_tokens),
|
| 57 |
+
)
|
| 58 |
+
result = get_runner().run(question.strip(), parse_candidates(candidate_text), config=config)
|
| 59 |
+
trace_rows = [
|
| 60 |
+
[
|
| 61 |
+
step.index,
|
| 62 |
+
round(step.entropy, 4),
|
| 63 |
+
step.prediction,
|
| 64 |
+
step.generated_tokens,
|
| 65 |
+
step.halted,
|
| 66 |
+
step.text,
|
| 67 |
+
]
|
| 68 |
+
for step in result.steps
|
| 69 |
+
]
|
| 70 |
+
final_probs = []
|
| 71 |
+
if result.steps:
|
| 72 |
+
final_probs = sorted(
|
| 73 |
+
result.steps[-1].probabilities.items(),
|
| 74 |
+
key=lambda item: item[1],
|
| 75 |
+
reverse=True,
|
| 76 |
+
)
|
| 77 |
+
final_probs = [[label, round(probability, 6)] for label, probability in final_probs]
|
| 78 |
+
|
| 79 |
+
status = "halted" if result.halted else "reached max steps"
|
| 80 |
+
summary = (
|
| 81 |
+
f"Answer: {result.answer}\n"
|
| 82 |
+
f"Status: {status}\n"
|
| 83 |
+
f"Generated reasoning tokens: {result.generated_tokens}"
|
| 84 |
+
)
|
| 85 |
+
return summary, result.reasoning, trace_rows, final_probs
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
with gr.Blocks(title="HALT-CoT") as demo:
|
| 89 |
+
gr.Markdown("# HALT-CoT")
|
| 90 |
+
with gr.Row():
|
| 91 |
+
with gr.Column(scale=2):
|
| 92 |
+
question = gr.Textbox(
|
| 93 |
+
label="Question",
|
| 94 |
+
lines=5,
|
| 95 |
+
value="If a shop has 12 apples and sells 5, how many apples are left?",
|
| 96 |
+
)
|
| 97 |
+
candidates = gr.Textbox(
|
| 98 |
+
label="Candidate answers",
|
| 99 |
+
lines=6,
|
| 100 |
+
value="5\n6\n7\n8\n9",
|
| 101 |
+
)
|
| 102 |
+
run_button = gr.Button("Run HALT-CoT", variant="primary")
|
| 103 |
+
with gr.Column(scale=1):
|
| 104 |
+
theta = gr.Slider(0.0, 2.0, value=DEFAULT_THETA, step=0.05, label="Entropy threshold")
|
| 105 |
+
consecutive = gr.Slider(1, 4, value=2, step=1, label="Consecutive low-entropy steps")
|
| 106 |
+
max_steps = gr.Slider(1, 12, value=8, step=1, label="Maximum steps")
|
| 107 |
+
step_max_new_tokens = gr.Slider(8, 160, value=64, step=8, label="Step token cap")
|
| 108 |
+
|
| 109 |
+
summary = gr.Textbox(label="Result", lines=3)
|
| 110 |
+
reasoning = gr.Textbox(label="Generated reasoning", lines=8)
|
| 111 |
+
trace = gr.Dataframe(
|
| 112 |
+
headers=["Step", "Entropy", "Prediction", "Tokens", "Halted", "Text"],
|
| 113 |
+
label="HALT trace",
|
| 114 |
+
)
|
| 115 |
+
probabilities = gr.Dataframe(headers=["Candidate", "Probability"], label="Final answer distribution")
|
| 116 |
+
|
| 117 |
+
run_button.click(
|
| 118 |
+
run_halt_cot,
|
| 119 |
+
inputs=[question, candidates, theta, consecutive, max_steps, step_max_new_tokens],
|
| 120 |
+
outputs=[summary, reasoning, trace, probabilities],
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
if __name__ == "__main__":
|
| 125 |
+
demo.launch()
|
examples/run_halt_cot.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Minimal HALT-CoT example using a Hugging Face causal LM."""
|
| 2 |
+
|
| 3 |
+
from halt_cot import HaltCoTConfig
|
| 4 |
+
from halt_cot.transformers_backend import HaltCoTForCausalLM
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def main() -> None:
|
| 8 |
+
runner = HaltCoTForCausalLM.from_pretrained("Qwen/Qwen2.5-0.5B-Instruct")
|
| 9 |
+
result = runner.run(
|
| 10 |
+
"If a shop has 12 apples and sells 5, how many apples are left?",
|
| 11 |
+
candidates=["5", "6", "7", "8", "9"],
|
| 12 |
+
config=HaltCoTConfig(theta=0.6, consecutive_low_entropy=2, max_steps=8),
|
| 13 |
+
)
|
| 14 |
+
print(result.answer)
|
| 15 |
+
print(result.reasoning)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
if __name__ == "__main__":
|
| 19 |
+
main()
|
halt_cot/__init__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""HALT-CoT: entropy-based early stopping for chain-of-thought reasoning."""
|
| 2 |
+
|
| 3 |
+
from .core import (
|
| 4 |
+
AnswerCandidate,
|
| 5 |
+
AnswerDistribution,
|
| 6 |
+
EntropyHaltingController,
|
| 7 |
+
HaltCoTConfig,
|
| 8 |
+
HaltCoTResult,
|
| 9 |
+
HaltCoTStep,
|
| 10 |
+
HaltDecision,
|
| 11 |
+
answer_distribution_from_scores,
|
| 12 |
+
entropy_from_probabilities,
|
| 13 |
+
format_reasoning,
|
| 14 |
+
integer_candidates,
|
| 15 |
+
multiple_choice_candidates,
|
| 16 |
+
normalize_candidates,
|
| 17 |
+
numeric_candidates_from_texts,
|
| 18 |
+
softmax,
|
| 19 |
+
yes_no_candidates,
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
__all__ = [
|
| 23 |
+
"AnswerCandidate",
|
| 24 |
+
"AnswerDistribution",
|
| 25 |
+
"EntropyHaltingController",
|
| 26 |
+
"HaltCoTConfig",
|
| 27 |
+
"HaltCoTResult",
|
| 28 |
+
"HaltCoTStep",
|
| 29 |
+
"HaltDecision",
|
| 30 |
+
"answer_distribution_from_scores",
|
| 31 |
+
"entropy_from_probabilities",
|
| 32 |
+
"format_reasoning",
|
| 33 |
+
"integer_candidates",
|
| 34 |
+
"multiple_choice_candidates",
|
| 35 |
+
"normalize_candidates",
|
| 36 |
+
"numeric_candidates_from_texts",
|
| 37 |
+
"softmax",
|
| 38 |
+
"yes_no_candidates",
|
| 39 |
+
]
|
halt_cot/cli.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Command line entrypoint for HALT-CoT."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import argparse
|
| 6 |
+
import json
|
| 7 |
+
|
| 8 |
+
from .core import (
|
| 9 |
+
HaltCoTConfig,
|
| 10 |
+
integer_candidates,
|
| 11 |
+
multiple_choice_candidates,
|
| 12 |
+
yes_no_candidates,
|
| 13 |
+
)
|
| 14 |
+
from .transformers_backend import HaltCoTForCausalLM
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def build_parser() -> argparse.ArgumentParser:
|
| 18 |
+
parser = argparse.ArgumentParser(description="Run HALT-CoT with a Hugging Face causal LM.")
|
| 19 |
+
parser.add_argument("--model", required=True, help="Hugging Face model id or local model path.")
|
| 20 |
+
parser.add_argument("--question", required=True, help="Question to answer.")
|
| 21 |
+
parser.add_argument(
|
| 22 |
+
"--candidate-set",
|
| 23 |
+
choices=("custom", "yes-no", "multiple-choice", "integers"),
|
| 24 |
+
default="custom",
|
| 25 |
+
help="Candidate answer set.",
|
| 26 |
+
)
|
| 27 |
+
parser.add_argument(
|
| 28 |
+
"--candidates",
|
| 29 |
+
nargs="*",
|
| 30 |
+
default=None,
|
| 31 |
+
help="Custom candidate labels, e.g. --candidates Yes No or --candidates A B C D E.",
|
| 32 |
+
)
|
| 33 |
+
parser.add_argument("--integer-start", type=int, default=0)
|
| 34 |
+
parser.add_argument("--integer-end", type=int, default=100)
|
| 35 |
+
parser.add_argument("--theta", type=float, default=0.6)
|
| 36 |
+
parser.add_argument("--max-steps", type=int, default=12)
|
| 37 |
+
parser.add_argument("--min-steps", type=int, default=1)
|
| 38 |
+
parser.add_argument("--consecutive", type=int, default=2)
|
| 39 |
+
parser.add_argument("--step-max-new-tokens", type=int, default=96)
|
| 40 |
+
parser.add_argument("--entropy-unit", choices=("bits", "nats"), default="bits")
|
| 41 |
+
parser.add_argument("--device-map", default=None, help="Optional Transformers device_map, e.g. auto.")
|
| 42 |
+
parser.add_argument("--trust-remote-code", action="store_true")
|
| 43 |
+
parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON.")
|
| 44 |
+
return parser
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def main(argv: list[str] | None = None) -> int:
|
| 48 |
+
args = build_parser().parse_args(argv)
|
| 49 |
+
candidates = _resolve_candidates(args)
|
| 50 |
+
config = HaltCoTConfig(
|
| 51 |
+
theta=args.theta,
|
| 52 |
+
max_steps=args.max_steps,
|
| 53 |
+
min_steps=args.min_steps,
|
| 54 |
+
consecutive_low_entropy=args.consecutive,
|
| 55 |
+
step_max_new_tokens=args.step_max_new_tokens,
|
| 56 |
+
entropy_unit=args.entropy_unit,
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
runner = HaltCoTForCausalLM.from_pretrained(
|
| 60 |
+
args.model,
|
| 61 |
+
config=config,
|
| 62 |
+
device_map=args.device_map,
|
| 63 |
+
trust_remote_code=args.trust_remote_code,
|
| 64 |
+
)
|
| 65 |
+
result = runner.run(args.question, candidates)
|
| 66 |
+
|
| 67 |
+
if args.json:
|
| 68 |
+
print(
|
| 69 |
+
json.dumps(
|
| 70 |
+
{
|
| 71 |
+
"answer": result.answer,
|
| 72 |
+
"halted": result.halted,
|
| 73 |
+
"generated_tokens": result.generated_tokens,
|
| 74 |
+
"steps": [
|
| 75 |
+
{
|
| 76 |
+
"index": step.index,
|
| 77 |
+
"text": step.text,
|
| 78 |
+
"entropy": step.entropy,
|
| 79 |
+
"prediction": step.prediction,
|
| 80 |
+
"probabilities": step.probabilities,
|
| 81 |
+
"halted": step.halted,
|
| 82 |
+
"generated_tokens": step.generated_tokens,
|
| 83 |
+
}
|
| 84 |
+
for step in result.steps
|
| 85 |
+
],
|
| 86 |
+
},
|
| 87 |
+
indent=2,
|
| 88 |
+
)
|
| 89 |
+
)
|
| 90 |
+
else:
|
| 91 |
+
print(f"Answer: {result.answer}")
|
| 92 |
+
print(f"Halted: {result.halted}")
|
| 93 |
+
print(f"Generated tokens: {result.generated_tokens}")
|
| 94 |
+
print("\nTrace:")
|
| 95 |
+
for step in result.steps:
|
| 96 |
+
marker = " HALT" if step.halted else ""
|
| 97 |
+
print(
|
| 98 |
+
f"- Step {step.index}: H={step.entropy:.3f} "
|
| 99 |
+
f"pred={step.prediction!r}{marker} | {step.text}"
|
| 100 |
+
)
|
| 101 |
+
return 0
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def _resolve_candidates(args: argparse.Namespace):
|
| 105 |
+
if args.candidate_set == "yes-no":
|
| 106 |
+
return yes_no_candidates()
|
| 107 |
+
if args.candidate_set == "multiple-choice":
|
| 108 |
+
labels = args.candidates or ["A", "B", "C", "D", "E"]
|
| 109 |
+
return multiple_choice_candidates(labels)
|
| 110 |
+
if args.candidate_set == "integers":
|
| 111 |
+
return integer_candidates(args.integer_start, args.integer_end)
|
| 112 |
+
if not args.candidates:
|
| 113 |
+
raise SystemExit("--candidates is required when --candidate-set custom")
|
| 114 |
+
return args.candidates
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
if __name__ == "__main__":
|
| 118 |
+
raise SystemExit(main())
|
halt_cot/core.py
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Core HALT-CoT primitives.
|
| 2 |
+
|
| 3 |
+
The functions in this module are dependency-free so they can be used in API
|
| 4 |
+
integrations, tests, and backends that are not based on Hugging Face
|
| 5 |
+
Transformers.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from dataclasses import dataclass, field
|
| 11 |
+
import math
|
| 12 |
+
import re
|
| 13 |
+
from typing import Iterable, Mapping, Sequence
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
EntropyUnit = str
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@dataclass(frozen=True)
|
| 20 |
+
class AnswerCandidate:
|
| 21 |
+
"""A possible final answer and optional textual aliases for token scoring."""
|
| 22 |
+
|
| 23 |
+
label: str
|
| 24 |
+
aliases: tuple[str, ...] = ()
|
| 25 |
+
|
| 26 |
+
def __post_init__(self) -> None:
|
| 27 |
+
label = self.label.strip()
|
| 28 |
+
if not label:
|
| 29 |
+
raise ValueError("AnswerCandidate.label must not be empty")
|
| 30 |
+
object.__setattr__(self, "label", label)
|
| 31 |
+
object.__setattr__(
|
| 32 |
+
self,
|
| 33 |
+
"aliases",
|
| 34 |
+
tuple(alias.strip() for alias in self.aliases if alias.strip()),
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
@property
|
| 38 |
+
def texts(self) -> tuple[str, ...]:
|
| 39 |
+
return (self.label, *self.aliases)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
@dataclass(frozen=True)
|
| 43 |
+
class AnswerDistribution:
|
| 44 |
+
"""Normalized answer probabilities and their entropy."""
|
| 45 |
+
|
| 46 |
+
probabilities: dict[str, float]
|
| 47 |
+
entropy: float
|
| 48 |
+
prediction: str
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
@dataclass(frozen=True)
|
| 52 |
+
class HaltCoTConfig:
|
| 53 |
+
"""Configuration for entropy-based chain-of-thought halting."""
|
| 54 |
+
|
| 55 |
+
theta: float = 0.6
|
| 56 |
+
max_steps: int = 12
|
| 57 |
+
min_steps: int = 1
|
| 58 |
+
consecutive_low_entropy: int = 2
|
| 59 |
+
entropy_unit: EntropyUnit = "bits"
|
| 60 |
+
step_max_new_tokens: int = 96
|
| 61 |
+
step_min_new_tokens: int = 4
|
| 62 |
+
prompt_template: str = "{question}\n\nLet's think step by step.\n"
|
| 63 |
+
step_prefix: str = "Step {step}: "
|
| 64 |
+
step_stop_strings: tuple[str, ...] = ("\nStep", "\nAnswer:", "\nFinal answer:", "\n")
|
| 65 |
+
answer_probe: str = "\nTherefore, the final answer is "
|
| 66 |
+
do_sample: bool = False
|
| 67 |
+
temperature: float = 0.0
|
| 68 |
+
top_p: float = 1.0
|
| 69 |
+
|
| 70 |
+
def __post_init__(self) -> None:
|
| 71 |
+
if self.theta < 0:
|
| 72 |
+
raise ValueError("theta must be non-negative")
|
| 73 |
+
if self.max_steps < 1:
|
| 74 |
+
raise ValueError("max_steps must be at least 1")
|
| 75 |
+
if self.min_steps < 1:
|
| 76 |
+
raise ValueError("min_steps must be at least 1")
|
| 77 |
+
if self.min_steps > self.max_steps:
|
| 78 |
+
raise ValueError("min_steps cannot exceed max_steps")
|
| 79 |
+
if self.consecutive_low_entropy < 1:
|
| 80 |
+
raise ValueError("consecutive_low_entropy must be at least 1")
|
| 81 |
+
if self.entropy_unit not in {"bits", "nats"}:
|
| 82 |
+
raise ValueError("entropy_unit must be 'bits' or 'nats'")
|
| 83 |
+
if self.step_max_new_tokens < 1:
|
| 84 |
+
raise ValueError("step_max_new_tokens must be at least 1")
|
| 85 |
+
if self.step_min_new_tokens < 0:
|
| 86 |
+
raise ValueError("step_min_new_tokens must be non-negative")
|
| 87 |
+
if self.temperature < 0:
|
| 88 |
+
raise ValueError("temperature must be non-negative")
|
| 89 |
+
if not 0 < self.top_p <= 1:
|
| 90 |
+
raise ValueError("top_p must be in (0, 1]")
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
@dataclass(frozen=True)
|
| 94 |
+
class HaltDecision:
|
| 95 |
+
"""Controller state after observing one entropy value."""
|
| 96 |
+
|
| 97 |
+
should_halt: bool
|
| 98 |
+
low_entropy_streak: int
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
@dataclass(frozen=True)
|
| 102 |
+
class HaltCoTStep:
|
| 103 |
+
"""One generated reasoning step plus HALT-CoT diagnostics."""
|
| 104 |
+
|
| 105 |
+
index: int
|
| 106 |
+
text: str
|
| 107 |
+
entropy: float
|
| 108 |
+
prediction: str
|
| 109 |
+
probabilities: dict[str, float]
|
| 110 |
+
halted: bool
|
| 111 |
+
generated_tokens: int = 0
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
@dataclass(frozen=True)
|
| 115 |
+
class HaltCoTResult:
|
| 116 |
+
"""Final output of a HALT-CoT run."""
|
| 117 |
+
|
| 118 |
+
answer: str
|
| 119 |
+
halted: bool
|
| 120 |
+
steps: tuple[HaltCoTStep, ...]
|
| 121 |
+
reasoning: str
|
| 122 |
+
generated_tokens: int
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
class EntropyHaltingController:
|
| 126 |
+
"""Implements the threshold and consecutive-step guard from the paper."""
|
| 127 |
+
|
| 128 |
+
def __init__(self, config: HaltCoTConfig):
|
| 129 |
+
self.config = config
|
| 130 |
+
self.low_entropy_streak = 0
|
| 131 |
+
|
| 132 |
+
def observe(self, entropy: float, step_index: int) -> HaltDecision:
|
| 133 |
+
if step_index < self.config.min_steps:
|
| 134 |
+
self.low_entropy_streak = 0
|
| 135 |
+
return HaltDecision(False, self.low_entropy_streak)
|
| 136 |
+
|
| 137 |
+
if entropy < self.config.theta:
|
| 138 |
+
self.low_entropy_streak += 1
|
| 139 |
+
else:
|
| 140 |
+
self.low_entropy_streak = 0
|
| 141 |
+
|
| 142 |
+
return HaltDecision(
|
| 143 |
+
should_halt=self.low_entropy_streak >= self.config.consecutive_low_entropy,
|
| 144 |
+
low_entropy_streak=self.low_entropy_streak,
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def normalize_candidates(
|
| 149 |
+
candidates: Sequence[str | AnswerCandidate],
|
| 150 |
+
) -> tuple[AnswerCandidate, ...]:
|
| 151 |
+
"""Convert user-provided strings or candidates into validated candidates."""
|
| 152 |
+
|
| 153 |
+
normalized: list[AnswerCandidate] = []
|
| 154 |
+
seen: set[str] = set()
|
| 155 |
+
for candidate in candidates:
|
| 156 |
+
item = candidate if isinstance(candidate, AnswerCandidate) else AnswerCandidate(str(candidate))
|
| 157 |
+
key = item.label.casefold()
|
| 158 |
+
if key in seen:
|
| 159 |
+
raise ValueError(f"Duplicate answer candidate: {item.label!r}")
|
| 160 |
+
seen.add(key)
|
| 161 |
+
normalized.append(item)
|
| 162 |
+
|
| 163 |
+
if len(normalized) < 2:
|
| 164 |
+
raise ValueError("At least two answer candidates are required")
|
| 165 |
+
return tuple(normalized)
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def softmax(scores: Mapping[str, float]) -> dict[str, float]:
|
| 169 |
+
"""Stable softmax over a label-to-score mapping."""
|
| 170 |
+
|
| 171 |
+
if not scores:
|
| 172 |
+
raise ValueError("scores must not be empty")
|
| 173 |
+
|
| 174 |
+
max_score = max(scores.values())
|
| 175 |
+
weights = {label: math.exp(score - max_score) for label, score in scores.items()}
|
| 176 |
+
total = sum(weights.values())
|
| 177 |
+
if total == 0 or not math.isfinite(total):
|
| 178 |
+
raise ValueError("scores produced an invalid softmax normalization")
|
| 179 |
+
return {label: weight / total for label, weight in weights.items()}
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def entropy_from_probabilities(
|
| 183 |
+
probabilities: Mapping[str, float],
|
| 184 |
+
entropy_unit: EntropyUnit = "bits",
|
| 185 |
+
) -> float:
|
| 186 |
+
"""Compute Shannon entropy for an answer distribution."""
|
| 187 |
+
|
| 188 |
+
if entropy_unit not in {"bits", "nats"}:
|
| 189 |
+
raise ValueError("entropy_unit must be 'bits' or 'nats'")
|
| 190 |
+
if not probabilities:
|
| 191 |
+
raise ValueError("probabilities must not be empty")
|
| 192 |
+
|
| 193 |
+
total = sum(probabilities.values())
|
| 194 |
+
if not math.isclose(total, 1.0, rel_tol=1e-6, abs_tol=1e-6):
|
| 195 |
+
raise ValueError(f"probabilities must sum to 1, got {total}")
|
| 196 |
+
|
| 197 |
+
log = math.log2 if entropy_unit == "bits" else math.log
|
| 198 |
+
return -sum(p * log(p) for p in probabilities.values() if p > 0)
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def answer_distribution_from_scores(
|
| 202 |
+
scores: Mapping[str, float],
|
| 203 |
+
entropy_unit: EntropyUnit = "bits",
|
| 204 |
+
) -> AnswerDistribution:
|
| 205 |
+
"""Turn answer logits/scores into probabilities, entropy, and argmax answer."""
|
| 206 |
+
|
| 207 |
+
probabilities = softmax(scores)
|
| 208 |
+
entropy = entropy_from_probabilities(probabilities, entropy_unit)
|
| 209 |
+
prediction = max(probabilities, key=probabilities.__getitem__)
|
| 210 |
+
return AnswerDistribution(
|
| 211 |
+
probabilities=dict(probabilities),
|
| 212 |
+
entropy=entropy,
|
| 213 |
+
prediction=prediction,
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def yes_no_candidates() -> tuple[AnswerCandidate, AnswerCandidate]:
|
| 218 |
+
"""Standard StrategyQA-style answer set."""
|
| 219 |
+
|
| 220 |
+
return (
|
| 221 |
+
AnswerCandidate("Yes", aliases=("yes", "YES")),
|
| 222 |
+
AnswerCandidate("No", aliases=("no", "NO")),
|
| 223 |
+
)
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
def multiple_choice_candidates(
|
| 227 |
+
labels: Iterable[str] = ("A", "B", "C", "D", "E"),
|
| 228 |
+
) -> tuple[AnswerCandidate, ...]:
|
| 229 |
+
"""Create option candidates with common first-token answer spellings."""
|
| 230 |
+
|
| 231 |
+
candidates = []
|
| 232 |
+
for raw_label in labels:
|
| 233 |
+
label = raw_label.strip()
|
| 234 |
+
if not label:
|
| 235 |
+
continue
|
| 236 |
+
candidates.append(
|
| 237 |
+
AnswerCandidate(
|
| 238 |
+
label,
|
| 239 |
+
aliases=(
|
| 240 |
+
label.lower(),
|
| 241 |
+
f"({label})",
|
| 242 |
+
f"{label}.",
|
| 243 |
+
f"{label})",
|
| 244 |
+
),
|
| 245 |
+
)
|
| 246 |
+
)
|
| 247 |
+
return normalize_candidates(candidates)
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
def integer_candidates(start: int = 0, end: int = 100) -> tuple[AnswerCandidate, ...]:
|
| 251 |
+
"""Numeric answer candidates for math-style tasks."""
|
| 252 |
+
|
| 253 |
+
if start > end:
|
| 254 |
+
raise ValueError("start cannot exceed end")
|
| 255 |
+
return tuple(AnswerCandidate(str(value)) for value in range(start, end + 1))
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
_NUMBER_RE = re.compile(r"[-+]?(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?")
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
def numeric_candidates_from_texts(
|
| 262 |
+
texts: Iterable[str],
|
| 263 |
+
extra_integers: tuple[int, int] | None = (0, 100),
|
| 264 |
+
max_candidates: int | None = None,
|
| 265 |
+
) -> tuple[AnswerCandidate, ...]:
|
| 266 |
+
"""Build a numeric candidate set from answer strings plus an integer range."""
|
| 267 |
+
|
| 268 |
+
labels: dict[str, None] = {}
|
| 269 |
+
for text in texts:
|
| 270 |
+
for match in _NUMBER_RE.findall(text):
|
| 271 |
+
labels[match.replace(",", "")] = None
|
| 272 |
+
|
| 273 |
+
if extra_integers is not None:
|
| 274 |
+
start, end = extra_integers
|
| 275 |
+
for value in range(start, end + 1):
|
| 276 |
+
labels[str(value)] = None
|
| 277 |
+
|
| 278 |
+
sorted_labels = sorted(labels, key=_numeric_sort_key)
|
| 279 |
+
if max_candidates is not None:
|
| 280 |
+
sorted_labels = sorted_labels[:max_candidates]
|
| 281 |
+
return normalize_candidates([AnswerCandidate(label) for label in sorted_labels])
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
def _numeric_sort_key(label: str) -> tuple[int, float | str]:
|
| 285 |
+
try:
|
| 286 |
+
return (0, float(label))
|
| 287 |
+
except ValueError:
|
| 288 |
+
return (1, label)
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
def format_reasoning(steps: Sequence[HaltCoTStep]) -> str:
|
| 292 |
+
"""Render generated steps for logs, demos, and model cards."""
|
| 293 |
+
|
| 294 |
+
return "\n".join(f"Step {step.index}: {step.text}".rstrip() for step in steps)
|
halt_cot/transformers_backend.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Hugging Face Transformers backend for HALT-CoT."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from dataclasses import replace
|
| 6 |
+
import math
|
| 7 |
+
from typing import Sequence
|
| 8 |
+
|
| 9 |
+
from .core import (
|
| 10 |
+
AnswerCandidate,
|
| 11 |
+
AnswerDistribution,
|
| 12 |
+
EntropyHaltingController,
|
| 13 |
+
HaltCoTConfig,
|
| 14 |
+
HaltCoTResult,
|
| 15 |
+
HaltCoTStep,
|
| 16 |
+
answer_distribution_from_scores,
|
| 17 |
+
format_reasoning,
|
| 18 |
+
normalize_candidates,
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class TextStopCriteria:
|
| 23 |
+
"""Transformers stopping criterion that halts when generated text hits a marker."""
|
| 24 |
+
|
| 25 |
+
def __init__(
|
| 26 |
+
self,
|
| 27 |
+
tokenizer,
|
| 28 |
+
prompt_length: int,
|
| 29 |
+
stop_strings: Sequence[str],
|
| 30 |
+
min_new_tokens: int,
|
| 31 |
+
):
|
| 32 |
+
from transformers import StoppingCriteria
|
| 33 |
+
|
| 34 |
+
class _Criterion(StoppingCriteria):
|
| 35 |
+
def __call__(inner_self, input_ids, scores, **kwargs) -> bool:
|
| 36 |
+
new_len = input_ids.shape[-1] - prompt_length
|
| 37 |
+
if new_len < min_new_tokens:
|
| 38 |
+
return False
|
| 39 |
+
tail_start = max(prompt_length, input_ids.shape[-1] - 64)
|
| 40 |
+
tail = tokenizer.decode(
|
| 41 |
+
input_ids[0, tail_start:],
|
| 42 |
+
skip_special_tokens=False,
|
| 43 |
+
)
|
| 44 |
+
return any(stop in tail for stop in stop_strings)
|
| 45 |
+
|
| 46 |
+
self.criterion = _Criterion()
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class HaltCoTForCausalLM:
|
| 50 |
+
"""Run HALT-CoT with any causal language model exposing next-token logits."""
|
| 51 |
+
|
| 52 |
+
def __init__(self, model, tokenizer, config: HaltCoTConfig | None = None):
|
| 53 |
+
self.model = model
|
| 54 |
+
self.tokenizer = tokenizer
|
| 55 |
+
self.config = config or HaltCoTConfig()
|
| 56 |
+
|
| 57 |
+
if getattr(self.tokenizer, "pad_token_id", None) is None:
|
| 58 |
+
eos_token = getattr(self.tokenizer, "eos_token", None)
|
| 59 |
+
if eos_token is not None:
|
| 60 |
+
self.tokenizer.pad_token = eos_token
|
| 61 |
+
|
| 62 |
+
@classmethod
|
| 63 |
+
def from_pretrained(
|
| 64 |
+
cls,
|
| 65 |
+
model_id: str,
|
| 66 |
+
*,
|
| 67 |
+
config: HaltCoTConfig | None = None,
|
| 68 |
+
torch_dtype: str | None = "auto",
|
| 69 |
+
device_map: str | None = None,
|
| 70 |
+
trust_remote_code: bool = False,
|
| 71 |
+
**kwargs,
|
| 72 |
+
) -> "HaltCoTForCausalLM":
|
| 73 |
+
"""Load a Transformers causal LM and tokenizer."""
|
| 74 |
+
|
| 75 |
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
| 76 |
+
|
| 77 |
+
tokenizer = AutoTokenizer.from_pretrained(
|
| 78 |
+
model_id,
|
| 79 |
+
trust_remote_code=trust_remote_code,
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
model_kwargs = dict(kwargs)
|
| 83 |
+
if torch_dtype is not None:
|
| 84 |
+
model_kwargs["torch_dtype"] = torch_dtype
|
| 85 |
+
if device_map is not None:
|
| 86 |
+
model_kwargs["device_map"] = device_map
|
| 87 |
+
model = AutoModelForCausalLM.from_pretrained(
|
| 88 |
+
model_id,
|
| 89 |
+
trust_remote_code=trust_remote_code,
|
| 90 |
+
**model_kwargs,
|
| 91 |
+
)
|
| 92 |
+
if device_map is None:
|
| 93 |
+
model.to(_default_device())
|
| 94 |
+
model.eval()
|
| 95 |
+
return cls(model=model, tokenizer=tokenizer, config=config)
|
| 96 |
+
|
| 97 |
+
def run(
|
| 98 |
+
self,
|
| 99 |
+
question: str,
|
| 100 |
+
candidates: Sequence[str | AnswerCandidate],
|
| 101 |
+
config: HaltCoTConfig | None = None,
|
| 102 |
+
) -> HaltCoTResult:
|
| 103 |
+
"""Generate reasoning steps until answer entropy crosses the threshold."""
|
| 104 |
+
|
| 105 |
+
run_config = config or self.config
|
| 106 |
+
answer_candidates = normalize_candidates(candidates)
|
| 107 |
+
controller = EntropyHaltingController(run_config)
|
| 108 |
+
context = run_config.prompt_template.format(question=question)
|
| 109 |
+
steps: list[HaltCoTStep] = []
|
| 110 |
+
generated_tokens = 0
|
| 111 |
+
last_distribution: AnswerDistribution | None = None
|
| 112 |
+
|
| 113 |
+
for index in range(1, run_config.max_steps + 1):
|
| 114 |
+
step_prompt = _ensure_trailing_newline(context) + run_config.step_prefix.format(step=index)
|
| 115 |
+
step_text, token_count = self.generate_step(step_prompt, run_config)
|
| 116 |
+
generated_tokens += token_count
|
| 117 |
+
step_text = step_text.strip()
|
| 118 |
+
context = f"{step_prompt}{step_text}\n"
|
| 119 |
+
|
| 120 |
+
distribution = self.answer_distribution(
|
| 121 |
+
context + run_config.answer_probe,
|
| 122 |
+
answer_candidates,
|
| 123 |
+
entropy_unit=run_config.entropy_unit,
|
| 124 |
+
)
|
| 125 |
+
last_distribution = distribution
|
| 126 |
+
decision = controller.observe(distribution.entropy, index)
|
| 127 |
+
|
| 128 |
+
steps.append(
|
| 129 |
+
HaltCoTStep(
|
| 130 |
+
index=index,
|
| 131 |
+
text=step_text,
|
| 132 |
+
entropy=distribution.entropy,
|
| 133 |
+
prediction=distribution.prediction,
|
| 134 |
+
probabilities=distribution.probabilities,
|
| 135 |
+
halted=decision.should_halt,
|
| 136 |
+
generated_tokens=token_count,
|
| 137 |
+
)
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
if decision.should_halt:
|
| 141 |
+
return HaltCoTResult(
|
| 142 |
+
answer=distribution.prediction,
|
| 143 |
+
halted=True,
|
| 144 |
+
steps=tuple(steps),
|
| 145 |
+
reasoning=format_reasoning(steps),
|
| 146 |
+
generated_tokens=generated_tokens,
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
if last_distribution is None:
|
| 150 |
+
raise RuntimeError("HALT-CoT run produced no steps")
|
| 151 |
+
|
| 152 |
+
return HaltCoTResult(
|
| 153 |
+
answer=last_distribution.prediction,
|
| 154 |
+
halted=False,
|
| 155 |
+
steps=tuple(steps),
|
| 156 |
+
reasoning=format_reasoning(steps),
|
| 157 |
+
generated_tokens=generated_tokens,
|
| 158 |
+
)
|
| 159 |
+
|
| 160 |
+
def generate_step(self, prompt: str, config: HaltCoTConfig) -> tuple[str, int]:
|
| 161 |
+
"""Generate one bounded reasoning step."""
|
| 162 |
+
|
| 163 |
+
import torch
|
| 164 |
+
from transformers import StoppingCriteriaList
|
| 165 |
+
|
| 166 |
+
inputs = self._encode(prompt)
|
| 167 |
+
prompt_length = inputs["input_ids"].shape[-1]
|
| 168 |
+
stopping_criteria = None
|
| 169 |
+
if config.step_stop_strings:
|
| 170 |
+
stopping_criteria = StoppingCriteriaList(
|
| 171 |
+
[
|
| 172 |
+
TextStopCriteria(
|
| 173 |
+
self.tokenizer,
|
| 174 |
+
prompt_length,
|
| 175 |
+
config.step_stop_strings,
|
| 176 |
+
config.step_min_new_tokens,
|
| 177 |
+
).criterion
|
| 178 |
+
]
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
generation_kwargs = {
|
| 182 |
+
"max_new_tokens": config.step_max_new_tokens,
|
| 183 |
+
"do_sample": config.do_sample,
|
| 184 |
+
"pad_token_id": self.tokenizer.pad_token_id,
|
| 185 |
+
"eos_token_id": self.tokenizer.eos_token_id,
|
| 186 |
+
"stopping_criteria": stopping_criteria,
|
| 187 |
+
}
|
| 188 |
+
if config.do_sample:
|
| 189 |
+
generation_kwargs["temperature"] = max(config.temperature, 1e-6)
|
| 190 |
+
generation_kwargs["top_p"] = config.top_p
|
| 191 |
+
|
| 192 |
+
with torch.inference_mode():
|
| 193 |
+
output_ids = self.model.generate(**inputs, **generation_kwargs)[0]
|
| 194 |
+
|
| 195 |
+
new_ids = output_ids[prompt_length:]
|
| 196 |
+
text = self.tokenizer.decode(new_ids, skip_special_tokens=True).lstrip()
|
| 197 |
+
text = _truncate_at_first_stop(text, config.step_stop_strings)
|
| 198 |
+
return text, int(new_ids.shape[-1])
|
| 199 |
+
|
| 200 |
+
def answer_distribution(
|
| 201 |
+
self,
|
| 202 |
+
context: str,
|
| 203 |
+
candidates: Sequence[AnswerCandidate],
|
| 204 |
+
*,
|
| 205 |
+
entropy_unit: str = "bits",
|
| 206 |
+
) -> AnswerDistribution:
|
| 207 |
+
"""Compute answer entropy from next-token logits over candidates."""
|
| 208 |
+
|
| 209 |
+
import torch
|
| 210 |
+
|
| 211 |
+
candidate_token_ids = first_token_ids_by_candidate(self.tokenizer, candidates)
|
| 212 |
+
inputs = self._encode(context)
|
| 213 |
+
with torch.inference_mode():
|
| 214 |
+
logits = self.model(**inputs).logits[0, -1, :].float()
|
| 215 |
+
|
| 216 |
+
scores: dict[str, float] = {}
|
| 217 |
+
for candidate in candidates:
|
| 218 |
+
token_ids = candidate_token_ids[candidate.label]
|
| 219 |
+
token_logits = logits[token_ids]
|
| 220 |
+
scores[candidate.label] = float(torch.logsumexp(token_logits, dim=0).item())
|
| 221 |
+
|
| 222 |
+
return answer_distribution_from_scores(scores, entropy_unit=entropy_unit)
|
| 223 |
+
|
| 224 |
+
def _encode(self, text: str):
|
| 225 |
+
inputs = self.tokenizer(text, return_tensors="pt")
|
| 226 |
+
return inputs.to(_model_device(self.model))
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def first_token_ids_by_candidate(
|
| 230 |
+
tokenizer,
|
| 231 |
+
candidates: Sequence[AnswerCandidate],
|
| 232 |
+
) -> dict[str, list[int]]:
|
| 233 |
+
"""Map each candidate to first-token ids for its label and aliases."""
|
| 234 |
+
|
| 235 |
+
mapping: dict[str, list[int]] = {}
|
| 236 |
+
for candidate in candidates:
|
| 237 |
+
token_ids: set[int] = set()
|
| 238 |
+
for text in candidate.texts:
|
| 239 |
+
for variant in _candidate_variants(text):
|
| 240 |
+
encoded = tokenizer.encode(variant, add_special_tokens=False)
|
| 241 |
+
if encoded:
|
| 242 |
+
token_ids.add(int(encoded[0]))
|
| 243 |
+
if not token_ids:
|
| 244 |
+
raise ValueError(f"Candidate {candidate.label!r} did not tokenize to any ids")
|
| 245 |
+
mapping[candidate.label] = sorted(token_ids)
|
| 246 |
+
return mapping
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
def with_config(base: HaltCoTConfig, **updates) -> HaltCoTConfig:
|
| 250 |
+
"""Return a modified config while preserving dataclass validation."""
|
| 251 |
+
|
| 252 |
+
return replace(base, **updates)
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
def _candidate_variants(text: str) -> tuple[str, ...]:
|
| 256 |
+
stripped = text.strip()
|
| 257 |
+
variants = {stripped, f" {stripped}"}
|
| 258 |
+
if stripped[:1].isalpha():
|
| 259 |
+
variants.add(stripped.lower())
|
| 260 |
+
variants.add(stripped.upper())
|
| 261 |
+
variants.add(f" {stripped.lower()}")
|
| 262 |
+
variants.add(f" {stripped.upper()}")
|
| 263 |
+
return tuple(variant for variant in variants if variant)
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
def _truncate_at_first_stop(text: str, stop_strings: Sequence[str]) -> str:
|
| 267 |
+
if not stop_strings:
|
| 268 |
+
return text
|
| 269 |
+
indexes = [text.find(stop) for stop in stop_strings if text.find(stop) >= 0]
|
| 270 |
+
if not indexes:
|
| 271 |
+
return text
|
| 272 |
+
return text[: min(indexes)]
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
def _ensure_trailing_newline(text: str) -> str:
|
| 276 |
+
return text if text.endswith("\n") else f"{text}\n"
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
def _model_device(model):
|
| 280 |
+
try:
|
| 281 |
+
return next(model.parameters()).device
|
| 282 |
+
except StopIteration:
|
| 283 |
+
return _default_device()
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
def _default_device():
|
| 287 |
+
import torch
|
| 288 |
+
|
| 289 |
+
if torch.cuda.is_available():
|
| 290 |
+
return torch.device("cuda")
|
| 291 |
+
return torch.device("cpu")
|
pyproject.toml
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[build-system]
|
| 2 |
+
requires = ["setuptools>=68"]
|
| 3 |
+
build-backend = "setuptools.build_meta"
|
| 4 |
+
|
| 5 |
+
[project]
|
| 6 |
+
name = "halt-cot"
|
| 7 |
+
version = "0.1.0"
|
| 8 |
+
description = "Entropy-based early stopping for chain-of-thought reasoning."
|
| 9 |
+
readme = "README.md"
|
| 10 |
+
requires-python = ">=3.10"
|
| 11 |
+
license = { text = "MIT License" }
|
| 12 |
+
authors = [{ name = "Yassir Laaouach" }]
|
| 13 |
+
keywords = ["chain-of-thought", "entropy", "early-stopping", "reasoning", "transformers"]
|
| 14 |
+
classifiers = [
|
| 15 |
+
"Development Status :: 3 - Alpha",
|
| 16 |
+
"Intended Audience :: Science/Research",
|
| 17 |
+
"License :: OSI Approved :: MIT License",
|
| 18 |
+
"Programming Language :: Python :: 3",
|
| 19 |
+
"Programming Language :: Python :: 3.10",
|
| 20 |
+
"Programming Language :: Python :: 3.11",
|
| 21 |
+
"Programming Language :: Python :: 3.12",
|
| 22 |
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
| 23 |
+
]
|
| 24 |
+
dependencies = []
|
| 25 |
+
|
| 26 |
+
[project.optional-dependencies]
|
| 27 |
+
transformers = [
|
| 28 |
+
"torch>=2.2",
|
| 29 |
+
"transformers>=4.44",
|
| 30 |
+
"accelerate>=0.33",
|
| 31 |
+
]
|
| 32 |
+
demo = [
|
| 33 |
+
"torch>=2.2",
|
| 34 |
+
"transformers>=4.44",
|
| 35 |
+
"accelerate>=0.33",
|
| 36 |
+
"gradio>=4.44",
|
| 37 |
+
"huggingface_hub>=0.24",
|
| 38 |
+
]
|
| 39 |
+
publish = [
|
| 40 |
+
"huggingface_hub>=0.24",
|
| 41 |
+
]
|
| 42 |
+
dev = [
|
| 43 |
+
"pytest>=8",
|
| 44 |
+
]
|
| 45 |
+
|
| 46 |
+
[project.scripts]
|
| 47 |
+
halt-cot = "halt_cot.cli:main"
|
| 48 |
+
|
| 49 |
+
[tool.setuptools.packages.find]
|
| 50 |
+
include = ["halt_cot*"]
|
| 51 |
+
|
| 52 |
+
[tool.pytest.ini_options]
|
| 53 |
+
testpaths = ["tests"]
|
| 54 |
+
addopts = "-q"
|
requirements.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
torch>=2.2
|
| 2 |
+
transformers>=4.44
|
| 3 |
+
accelerate>=0.33
|
| 4 |
+
gradio>=4.44
|
| 5 |
+
huggingface_hub>=0.24
|
scripts/publish_to_hf.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Upload the current HALT-CoT folder to Hugging Face."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import argparse
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
from huggingface_hub import create_repo, upload_folder
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
DEFAULT_IGNORE = [
|
| 12 |
+
".git",
|
| 13 |
+
".git/*",
|
| 14 |
+
".pytest_cache",
|
| 15 |
+
".pytest_cache/*",
|
| 16 |
+
"__pycache__",
|
| 17 |
+
"**/__pycache__/*",
|
| 18 |
+
"*.pyc",
|
| 19 |
+
"*.egg-info",
|
| 20 |
+
"*.egg-info/*",
|
| 21 |
+
"build",
|
| 22 |
+
"build/*",
|
| 23 |
+
"dist",
|
| 24 |
+
"dist/*",
|
| 25 |
+
".env",
|
| 26 |
+
".env.*",
|
| 27 |
+
]
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def build_parser() -> argparse.ArgumentParser:
|
| 31 |
+
parser = argparse.ArgumentParser(description="Publish HALT-CoT to Hugging Face.")
|
| 32 |
+
parser.add_argument("--repo-id", required=True, help="Example: username/halt-cot")
|
| 33 |
+
parser.add_argument("--repo-type", choices=("space", "model", "dataset"), default="space")
|
| 34 |
+
parser.add_argument("--private", action="store_true")
|
| 35 |
+
parser.add_argument("--commit-message", default="Publish HALT-CoT")
|
| 36 |
+
return parser
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def main(argv: list[str] | None = None) -> int:
|
| 40 |
+
args = build_parser().parse_args(argv)
|
| 41 |
+
project_root = Path(__file__).resolve().parents[1]
|
| 42 |
+
|
| 43 |
+
create_repo(
|
| 44 |
+
repo_id=args.repo_id,
|
| 45 |
+
repo_type=args.repo_type,
|
| 46 |
+
private=args.private,
|
| 47 |
+
exist_ok=True,
|
| 48 |
+
space_sdk="gradio" if args.repo_type == "space" else None,
|
| 49 |
+
)
|
| 50 |
+
upload_folder(
|
| 51 |
+
repo_id=args.repo_id,
|
| 52 |
+
repo_type=args.repo_type,
|
| 53 |
+
folder_path=str(project_root),
|
| 54 |
+
path_in_repo=".",
|
| 55 |
+
ignore_patterns=DEFAULT_IGNORE,
|
| 56 |
+
commit_message=args.commit_message,
|
| 57 |
+
)
|
| 58 |
+
print(f"Uploaded {project_root} to {args.repo_type} repo {args.repo_id}")
|
| 59 |
+
return 0
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
if __name__ == "__main__":
|
| 63 |
+
raise SystemExit(main())
|
tests/test_core.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import math
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
|
| 5 |
+
from halt_cot import (
|
| 6 |
+
AnswerCandidate,
|
| 7 |
+
EntropyHaltingController,
|
| 8 |
+
HaltCoTConfig,
|
| 9 |
+
answer_distribution_from_scores,
|
| 10 |
+
entropy_from_probabilities,
|
| 11 |
+
integer_candidates,
|
| 12 |
+
multiple_choice_candidates,
|
| 13 |
+
normalize_candidates,
|
| 14 |
+
numeric_candidates_from_texts,
|
| 15 |
+
yes_no_candidates,
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def test_uniform_binary_entropy_is_one_bit():
|
| 20 |
+
entropy = entropy_from_probabilities({"Yes": 0.5, "No": 0.5}, entropy_unit="bits")
|
| 21 |
+
assert entropy == pytest.approx(1.0)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def test_answer_distribution_uses_softmax_and_argmax():
|
| 25 |
+
distribution = answer_distribution_from_scores({"A": 4.0, "B": 0.0}, entropy_unit="bits")
|
| 26 |
+
assert distribution.prediction == "A"
|
| 27 |
+
assert distribution.probabilities["A"] > 0.98
|
| 28 |
+
assert 0.0 < distribution.entropy < 0.2
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def test_entropy_validates_probability_mass():
|
| 32 |
+
with pytest.raises(ValueError, match="sum to 1"):
|
| 33 |
+
entropy_from_probabilities({"A": 0.2, "B": 0.2})
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def test_halting_controller_requires_consecutive_low_entropy_steps():
|
| 37 |
+
controller = EntropyHaltingController(
|
| 38 |
+
HaltCoTConfig(theta=0.5, consecutive_low_entropy=2, max_steps=4)
|
| 39 |
+
)
|
| 40 |
+
first = controller.observe(entropy=0.4, step_index=1)
|
| 41 |
+
second = controller.observe(entropy=0.3, step_index=2)
|
| 42 |
+
assert not first.should_halt
|
| 43 |
+
assert second.should_halt
|
| 44 |
+
assert second.low_entropy_streak == 2
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def test_halting_controller_respects_min_steps():
|
| 48 |
+
controller = EntropyHaltingController(
|
| 49 |
+
HaltCoTConfig(theta=0.5, min_steps=3, consecutive_low_entropy=1, max_steps=4)
|
| 50 |
+
)
|
| 51 |
+
assert not controller.observe(entropy=0.1, step_index=1).should_halt
|
| 52 |
+
assert not controller.observe(entropy=0.1, step_index=2).should_halt
|
| 53 |
+
assert controller.observe(entropy=0.1, step_index=3).should_halt
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def test_candidate_normalization_rejects_duplicates():
|
| 57 |
+
with pytest.raises(ValueError, match="Duplicate"):
|
| 58 |
+
normalize_candidates(["A", "a"])
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def test_yes_no_candidates_include_aliases():
|
| 62 |
+
yes, no = yes_no_candidates()
|
| 63 |
+
assert yes.label == "Yes"
|
| 64 |
+
assert "YES" in yes.aliases
|
| 65 |
+
assert no.label == "No"
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def test_multiple_choice_candidates_include_common_forms():
|
| 69 |
+
candidates = multiple_choice_candidates(["A", "B"])
|
| 70 |
+
assert candidates[0] == AnswerCandidate("A", aliases=("a", "(A)", "A.", "A)"))
|
| 71 |
+
assert candidates[1].label == "B"
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def test_integer_candidates_are_inclusive():
|
| 75 |
+
candidates = integer_candidates(2, 4)
|
| 76 |
+
assert [candidate.label for candidate in candidates] == ["2", "3", "4"]
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def test_numeric_candidates_from_texts_extracts_numbers_and_range():
|
| 80 |
+
candidates = numeric_candidates_from_texts(
|
| 81 |
+
["The answer is 1,234.5", "Then subtract -7."],
|
| 82 |
+
extra_integers=(0, 2),
|
| 83 |
+
)
|
| 84 |
+
labels = [candidate.label for candidate in candidates]
|
| 85 |
+
assert "-7" in labels
|
| 86 |
+
assert "1234.5" in labels
|
| 87 |
+
assert {"0", "1", "2"}.issubset(labels)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def test_config_validation():
|
| 91 |
+
with pytest.raises(ValueError, match="theta"):
|
| 92 |
+
HaltCoTConfig(theta=-math.inf)
|