Jour commited on
Commit
252f2e5
·
verified ·
1 Parent(s): 6a7e22a

Ouroboros detector v7: merged backbone, heads, calibrator, model card

Browse files
.gitattributes CHANGED
@@ -33,3 +33,8 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ assets/architecture.png filter=lfs diff=lfs merge=lfs -text
37
+ assets/benchmarks.png filter=lfs diff=lfs merge=lfs -text
38
+ assets/generators.png filter=lfs diff=lfs merge=lfs -text
39
+ assets/interpretability.png filter=lfs diff=lfs merge=lfs -text
40
+ tokenizer.json filter=lfs diff=lfs merge=lfs -text
README.md ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ language:
4
+ - en
5
+ - fr
6
+ library_name: transformers
7
+ base_model: Qwen/Qwen3-1.7B-Base
8
+ pipeline_tag: text-classification
9
+ tags:
10
+ - ai-text-detection
11
+ - ai-generated-text
12
+ - token-classification
13
+ - authorship
14
+ - lora
15
+ - qwen3
16
+ ---
17
+
18
+ <div align="center">
19
+ <img src="assets/banner.png" alt="Ouroboros" width="100%">
20
+ </div>
21
+
22
+ # Ouroboros detector (v7)
23
+
24
+ An open, **token-level detector of AI-written text**. For a document it returns character-aligned segments labelled `human`, `ai-assisted` or `ai-generated` with confidences, a weighted AI fraction, a `human` / `mixed` / `ai` verdict, and a 4-way "was this humanized?" probability (`human`, `ai-generated`, `humanized-ai`, `mixed-authorship`).
25
+
26
+ > **Unofficial.** This is an independent, open reimplementation of the method of the *Pangram 4* technical report ([arXiv:2607.27183](https://arxiv.org/abs/2607.27183)). It is not affiliated with or endorsed by Pangram, and it is not their model, weights or data.
27
+
28
+ > **Built end to end by Claude Opus 5.5.** The architecture implementation, data pipeline, training, evaluation, interpretability study, bias audit and this model card were designed, written and run end to end by Claude Opus 5.5 (Anthropic) as an autonomous coding agent, at the direction of the author, who set the goals and reviewed the results.
29
+
30
+ - **Code, training pipeline, dataset builder, demo:** https://github.com/Jourdelune/ouroboros-detector
31
+ - **Backbone:** [Qwen3-1.7B-Base](https://huggingface.co/Qwen/Qwen3-1.7B-Base) with a LoRA adapter (r = 32) **merged into the weights** (bf16, 3.4 GB), plus four single-layer heads.
32
+ - **Languages:** evaluated on English and French; other languages appear in the training data but are not benchmarked.
33
+
34
+ ## Use
35
+
36
+ ```bash
37
+ pip install "git+https://github.com/Jourdelune/ouroboros-detector.git"
38
+ ```
39
+
40
+ ```python
41
+ from ouroboros.infer.predict import Predictor
42
+
43
+ detector = Predictor.from_pretrained("Jour/ouroboros-detector")
44
+ result = detector.predict(text) # texts of at least ~50 words
45
+ print(result.document_label, result.weighted_ai_fraction)
46
+ for seg in result.segments:
47
+ print(seg.start, seg.end, seg.label_name, seg.confidence)
48
+ print(detector.humanizer(text))
49
+ ```
50
+
51
+ The model needs the `ouroboros` package: it implements Repeat2 inputs, the four heads, sliding windows, calibration and CRF decoding. `AutoModel.from_pretrained` alone loads only the backbone.
52
+
53
+ ## Architecture
54
+
55
+ One causal backbone fed the window twice (**Repeat2**, so every supervised token has seen the whole window) and four heads: segment (`D→15`, weighted AI fraction), tokenwise provenance (`D→3`), mixed-authorship (`D→2`), humanizer probe (`D→4`, stop-gradient). Windows of 512 tokens with stride 256, then calibration and a linear-chain CRF.
56
+
57
+ <img src="assets/architecture.png" alt="Architecture" width="100%">
58
+
59
+ ## Evaluation
60
+
61
+ Held-out test split of the project's corpus: 12,000 documents (4,531 human, 6,821 AI, 648 mixed), split by source document. Operating point chosen on a separate calibration split for a 0.5 % false-positive target.
62
+
63
+ | metric | v7 |
64
+ |---|---:|
65
+ | False-positive rate | 1.35 % |
66
+ | False-negative rate (AI or mixed missed) | 0.94 % |
67
+ | AUROC | 0.9915 |
68
+ | TPR @ 1 % FPR | 98.9 % |
69
+ | Token accuracy | 98.4 % |
70
+ | Token F1 — human / AI-assisted / AI-generated | 0.986 / 0.714 / 0.988 |
71
+ | Mixed-document accuracy | 77.2 % |
72
+ | 4-way humanizer head accuracy / recall on `humanized-ai` | 95.0 % / 56.8 % |
73
+
74
+ <img src="assets/benchmarks.png" alt="Benchmarks" width="100%">
75
+
76
+ These numbers are **in-distribution** (same corpus families as training) and **not comparable** to the Pangram report's, which uses a different, proprietary corpus and backbone.
77
+
78
+ ## Known limitations and biases
79
+
80
+ <img src="assets/biases.png" alt="Known biases" width="75%">
81
+
82
+ - **Higher false-positive rate** on French human text (7.0 % vs 1.0 % in English), on very short text (< 80 words: 6.5 %), and on human text formatted like an assistant answer (bullets / bold / headers: 8.8 %).
83
+ - **Format and encoding shortcuts:** human text split into paragraphs is flagged 14.5 % of the time; inserting four Cyrillic look-alike letters into a human text makes it read as AI in 86 % of cases. Normalise confusable characters before inference.
84
+ - **Not robust to adaptive adversaries:** a rewriting loop with access to the detector's score made 70–88 % of a small held-out set (50 French + 50 English passages) read as human while passing automatic fidelity checks.
85
+ - Not supported: texts under ~50 words. `ai-assisted` is the weakest class (F1 0.71).
86
+ - **Do not use a verdict as the sole basis for a decision about a person.** AI-text detectors can wrongly flag non-native writers and formal prose.
87
+
88
+ ## What it looks at (interpretability)
89
+
90
+ Measured on held-out passages with 95 % intervals: a linear probe on the *untrained* Qwen3-1.7B-Base already separates classes at 95 %; fine-tuning raises it to 98.5 % and makes it language-independent (French→English transfer 99.5 % vs 91.5 %). The verdict is written in layers 18–27, mostly by two attention heads and the last MLP; sentence-final periods (2.4 % of tokens) carry 28 % of the evidence and paragraph breaks (0.8 %) 21 %; shuffling words inside sentences removes most of the signal while surface edits barely move it; the score is uncorrelated with a base language model's surprisal.
91
+
92
+ <img src="assets/interpretability.png" alt="Interpretability" width="100%">
93
+
94
+ ## Training data
95
+
96
+ Public sources only: RAID, MAGE, COLING-2025 MGT, ai-text-detection-pile, `dmitva/human_ai_generated_text`, Cosmopedia, WildChat, UltraChat, OpenHermes-2.5, arena preference sets, Amazon/Yelp/IMDB reviews, arXiv abstracts, FineWeb-2 (French) and FineWeb-Edu, Aya, Wikipedia, French instruction sets and several public distillation sets from recent frontier models (≈ 1.09 M training documents including augmentations). The dataset builder is in the GitHub repository.
97
+
98
+ > **Licences.** The weights are released under Apache-2.0 and the backbone is Apache-2.0, but the training data comes from many datasets with their own terms (some research-only or non-commercial). Check them before any commercial use. No dataset is redistributed here.
99
+
100
+ ## Files
101
+
102
+ `model.safetensors`, `config.json` (merged backbone) · `heads.pt` (the four heads) · `calibrator.json` (decoder calibration, 0.5 % FPR target) · `ouroboros_config.yaml` (inference settings) · tokenizer files.
103
+
104
+ ## Citation
105
+
106
+ ```bibtex
107
+ @software{ouroboros_detector,
108
+ title = {Ouroboros: an open token-level AI-text detector},
109
+ author = {Jourdelune},
110
+ year = {2026},
111
+ note = {Unofficial reimplementation of the Pangram 4 technical report (arXiv:2607.27183); built end to end with Claude Opus 5.5},
112
+ url = {https://github.com/Jourdelune/ouroboros-detector}
113
+ }
114
+ ```
assets/architecture.png ADDED

Git LFS Details

  • SHA256: af9808cc421714c737ba065fd9cdd7f09db3334c5c9415cf8ab94694114c95df
  • Pointer size: 131 Bytes
  • Size of remote file: 112 kB
assets/banner.png ADDED
assets/benchmarks.png ADDED

Git LFS Details

  • SHA256: c43f655fed78fd266dec67ea0dd668e494416037df25acc0dc419cf8fc7d59ad
  • Pointer size: 131 Bytes
  • Size of remote file: 128 kB
assets/biases.png ADDED
assets/generators.png ADDED

Git LFS Details

  • SHA256: 16bc0a6b55cfe2c029a4b3923ea5cfd9894606a961347c85090558a8083946cc
  • Pointer size: 131 Bytes
  • Size of remote file: 180 kB
assets/interpretability.png ADDED

Git LFS Details

  • SHA256: ca3bc787d708ee711b82178d4ab9a01321fecb2cdb88dcc0fdca3113fa4c6d7c
  • Pointer size: 131 Bytes
  • Size of remote file: 140 kB
calibrator.json ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "intercepts": [
3
+ -0.13786710811109545,
4
+ 0.1736658319082379,
5
+ -0.03579872379724635
6
+ ],
7
+ "weights": [
8
+ [
9
+ 0.6090387031621195,
10
+ -0.2203546534333896,
11
+ 0.05042216278611674,
12
+ -0.043796612923324964,
13
+ -0.16172733427658842,
14
+ -0.07141518106119561
15
+ ],
16
+ [
17
+ -0.47584707836701884,
18
+ 0.44584154734933923,
19
+ -0.53091849183712,
20
+ 0.18571772229692793,
21
+ 0.344271744390476,
22
+ 0.030821937669911974
23
+ ],
24
+ [
25
+ -0.13319162479510294,
26
+ -0.22548689391594745,
27
+ 0.4804963290510028,
28
+ -0.1419211093736046,
29
+ -0.18254441011389036,
30
+ 0.040593243391284464
31
+ ]
32
+ ],
33
+ "deltas": [
34
+ 1.5,
35
+ 0.0,
36
+ 0.0
37
+ ],
38
+ "smoothness_lambda": 4.0,
39
+ "mixed_gamma": 0.25,
40
+ "identity": false
41
+ }
chat_template.jinja ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {%- if tools %}
2
+ {{- '<|im_start|>system\n' }}
3
+ {%- if messages[0].role == 'system' %}
4
+ {{- messages[0].content + '\n\n' }}
5
+ {%- endif %}
6
+ {{- "# Tools\n\nYou may call one or more functions to assist with the user query.\n\nYou are provided with function signatures within <tools></tools> XML tags:\n<tools>" }}
7
+ {%- for tool in tools %}
8
+ {{- "\n" }}
9
+ {{- tool | tojson }}
10
+ {%- endfor %}
11
+ {{- "\n</tools>\n\nFor each function call, return a json object with function name and arguments within <tool_call></tool_call> XML tags:\n<tool_call>\n{\"name\": <function-name>, \"arguments\": <args-json-object>}\n</tool_call><|im_end|>\n" }}
12
+ {%- else %}
13
+ {%- if messages[0].role == 'system' %}
14
+ {{- '<|im_start|>system\n' + messages[0].content + '<|im_end|>\n' }}
15
+ {%- endif %}
16
+ {%- endif %}
17
+ {%- set ns = namespace(multi_step_tool=true, last_query_index=messages|length - 1) %}
18
+ {%- for message in messages[::-1] %}
19
+ {%- set index = (messages|length - 1) - loop.index0 %}
20
+ {%- if ns.multi_step_tool and message.role == "user" and not(message.content.startswith('<tool_response>') and message.content.endswith('</tool_response>')) %}
21
+ {%- set ns.multi_step_tool = false %}
22
+ {%- set ns.last_query_index = index %}
23
+ {%- endif %}
24
+ {%- endfor %}
25
+ {%- for message in messages %}
26
+ {%- if (message.role == "user") or (message.role == "system" and not loop.first) %}
27
+ {{- '<|im_start|>' + message.role + '\n' + message.content + '<|im_end|>' + '\n' }}
28
+ {%- elif message.role == "assistant" %}
29
+ {%- set content = message.content %}
30
+ {%- set reasoning_content = '' %}
31
+ {%- if message.reasoning_content is defined and message.reasoning_content is not none %}
32
+ {%- set reasoning_content = message.reasoning_content %}
33
+ {%- else %}
34
+ {%- if '</think>' in message.content %}
35
+ {%- set content = message.content.split('</think>')[-1].lstrip('\n') %}
36
+ {%- set reasoning_content = message.content.split('</think>')[0].rstrip('\n').split('<think>')[-1].lstrip('\n') %}
37
+ {%- endif %}
38
+ {%- endif %}
39
+ {%- if loop.index0 > ns.last_query_index %}
40
+ {%- if loop.last or (not loop.last and reasoning_content) %}
41
+ {{- '<|im_start|>' + message.role + '\n<think>\n' + reasoning_content.strip('\n') + '\n</think>\n\n' + content.lstrip('\n') }}
42
+ {%- else %}
43
+ {{- '<|im_start|>' + message.role + '\n' + content }}
44
+ {%- endif %}
45
+ {%- else %}
46
+ {{- '<|im_start|>' + message.role + '\n' + content }}
47
+ {%- endif %}
48
+ {%- if message.tool_calls %}
49
+ {%- for tool_call in message.tool_calls %}
50
+ {%- if (loop.first and content) or (not loop.first) %}
51
+ {{- '\n' }}
52
+ {%- endif %}
53
+ {%- if tool_call.function %}
54
+ {%- set tool_call = tool_call.function %}
55
+ {%- endif %}
56
+ {{- '<tool_call>\n{"name": "' }}
57
+ {{- tool_call.name }}
58
+ {{- '", "arguments": ' }}
59
+ {%- if tool_call.arguments is string %}
60
+ {{- tool_call.arguments }}
61
+ {%- else %}
62
+ {{- tool_call.arguments | tojson }}
63
+ {%- endif %}
64
+ {{- '}\n</tool_call>' }}
65
+ {%- endfor %}
66
+ {%- endif %}
67
+ {{- '<|im_end|>\n' }}
68
+ {%- elif message.role == "tool" %}
69
+ {%- if loop.first or (messages[loop.index0 - 1].role != "tool") %}
70
+ {{- '<|im_start|>user' }}
71
+ {%- endif %}
72
+ {{- '\n<tool_response>\n' }}
73
+ {{- message.content }}
74
+ {{- '\n</tool_response>' }}
75
+ {%- if loop.last or (messages[loop.index0 + 1].role != "tool") %}
76
+ {{- '<|im_end|>\n' }}
77
+ {%- endif %}
78
+ {%- endif %}
79
+ {%- endfor %}
80
+ {%- if add_generation_prompt %}
81
+ {{- '<|im_start|>assistant\n' }}
82
+ {%- if enable_thinking is defined and enable_thinking is false %}
83
+ {{- '<think>\n\n</think>\n\n' }}
84
+ {%- endif %}
85
+ {%- endif %}
config.json ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "architectures": [
3
+ "Qwen3Model"
4
+ ],
5
+ "attention_bias": false,
6
+ "attention_dropout": 0.0,
7
+ "bos_token_id": 151643,
8
+ "dtype": "bfloat16",
9
+ "eos_token_id": 151643,
10
+ "head_dim": 128,
11
+ "hidden_act": "silu",
12
+ "hidden_size": 2048,
13
+ "initializer_range": 0.02,
14
+ "intermediate_size": 6144,
15
+ "layer_types": [
16
+ "full_attention",
17
+ "full_attention",
18
+ "full_attention",
19
+ "full_attention",
20
+ "full_attention",
21
+ "full_attention",
22
+ "full_attention",
23
+ "full_attention",
24
+ "full_attention",
25
+ "full_attention",
26
+ "full_attention",
27
+ "full_attention",
28
+ "full_attention",
29
+ "full_attention",
30
+ "full_attention",
31
+ "full_attention",
32
+ "full_attention",
33
+ "full_attention",
34
+ "full_attention",
35
+ "full_attention",
36
+ "full_attention",
37
+ "full_attention",
38
+ "full_attention",
39
+ "full_attention",
40
+ "full_attention",
41
+ "full_attention",
42
+ "full_attention",
43
+ "full_attention"
44
+ ],
45
+ "max_position_embeddings": 32768,
46
+ "max_window_layers": 28,
47
+ "model_type": "qwen3",
48
+ "num_attention_heads": 16,
49
+ "num_hidden_layers": 28,
50
+ "num_key_value_heads": 8,
51
+ "pad_token_id": null,
52
+ "rms_norm_eps": 1e-06,
53
+ "rope_parameters": {
54
+ "rope_theta": 1000000,
55
+ "rope_type": "default"
56
+ },
57
+ "sliding_window": null,
58
+ "tie_word_embeddings": true,
59
+ "transformers_version": "5.17.0",
60
+ "use_cache": true,
61
+ "use_sliding_window": false,
62
+ "vocab_size": 151936
63
+ }
heads.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f1caa1bcf98884af6aa6edb40e370b767390c4768220ea0e5112a5490ee94df2
3
+ size 199985
model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:9becd7103e67152a8c8054eabec1dd3a817f6515c4a12dee4576389ec764f8f4
3
+ size 3441183752
ouroboros_config.yaml ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Inference / training settings of the released model (Ouroboros v7, stage 2). `backbone.name` is replaced by the
2
+ # directory this file lives in when the model is loaded.
3
+ backbone:
4
+ name: .
5
+ dtype: bfloat16
6
+ attn_implementation: sdpa
7
+ gradient_checkpointing: true
8
+ trust_remote_code: false
9
+ lora:
10
+ r: 32
11
+ alpha: 64
12
+ dropout: 0.05
13
+ target_modules: [q_proj, k_proj, v_proj, o_proj, gate_proj, up_proj, down_proj]
14
+ target_routed_experts: false
15
+ data:
16
+ train_shards: [data/corpus/train.jsonl]
17
+ eval_shards: [data/corpus/eval.jsonl]
18
+ calibration_shards: [data/corpus/calibration.jsonl]
19
+ test_shards: [data/corpus/test.jsonl]
20
+ window_tokens: 512
21
+ stride_tokens: 256
22
+ min_words: 50
23
+ max_windows_per_doc: 2
24
+ mixed_threshold: 0.15
25
+ soft_bucket_sharpness: 1.0
26
+ seed: 0
27
+ train:
28
+ output_dir: runs/ouroboros
29
+ stage: 2
30
+ steps: 6000
31
+ batch_size: 4
32
+ grad_accum: 4
33
+ lr: 5.0e-05
34
+ head_lr: 0.0005
35
+ weight_decay: 0.01
36
+ warmup_ratio: 0.03
37
+ max_grad_norm: 1.0
38
+ log_every: 25
39
+ eval_every: 500
40
+ save_every: 500
41
+ seed: 0
42
+ w_segment: 1.0
43
+ w_token: 1.0
44
+ w_mixed: 0.5
45
+ w_humanizer: 0.25
46
+ postprocess:
47
+ smoothness_lambda: 4.0
48
+ mixed_gamma: 0.5
49
+ class_prior_delta: [0.0, 0.0, 0.0]
50
+ min_segment_tokens: 32
51
+ human_fraction_threshold: 0.9
52
+ ai_fraction_threshold: 0.8
tokenizer.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:be75606093db2094d7cd20f3c2f385c212750648bd6ea4fb2bf507a6a4c55506
3
+ size 11422650
tokenizer_config.json ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "add_prefix_space": false,
3
+ "backend": "tokenizers",
4
+ "bos_token": null,
5
+ "clean_up_tokenization_spaces": false,
6
+ "eos_token": "<|endoftext|>",
7
+ "errors": "replace",
8
+ "extra_special_tokens": [
9
+ "<|im_start|>",
10
+ "<|im_end|>",
11
+ "<|object_ref_start|>",
12
+ "<|object_ref_end|>",
13
+ "<|box_start|>",
14
+ "<|box_end|>",
15
+ "<|quad_start|>",
16
+ "<|quad_end|>",
17
+ "<|vision_start|>",
18
+ "<|vision_end|>",
19
+ "<|vision_pad|>",
20
+ "<|image_pad|>",
21
+ "<|video_pad|>"
22
+ ],
23
+ "is_local": true,
24
+ "local_files_only": false,
25
+ "model_max_length": 131072,
26
+ "pad_token": "<|endoftext|>",
27
+ "split_special_tokens": false,
28
+ "tokenizer_class": "Qwen2Tokenizer",
29
+ "unk_token": null
30
+ }