Upload 6 files
Browse files- README.md +87 -3
- example_usage.py +135 -0
- features.py +302 -0
- hybrid_model_best.pt +3 -0
- ling_scaler.pkl +3 -0
- model.py +115 -0
README.md
CHANGED
|
@@ -1,3 +1,87 @@
|
|
| 1 |
-
-
|
| 2 |
-
|
| 3 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Hybrid RoBERTa + Linguistic-Features Detector
|
| 2 |
+
|
| 3 |
+
Best checkpoint from the master's thesis on hybrid machine learning approaches for detection of LLM-generated English texts by Bohdan Zhvalevskyi.
|
| 4 |
+
It combines a fine-tuned `roberta-base` CLS embedding with 25 normalized
|
| 5 |
+
linguistic features through a feature-attention module and a learned gated
|
| 6 |
+
fusion, followed by a 2-class classifier (`0 = human`, `1 = machine`).
|
| 7 |
+
|
| 8 |
+
## Files
|
| 9 |
+
|
| 10 |
+
| File | Purpose |
|
| 11 |
+
|------|---------|
|
| 12 |
+
| `hybrid_model_best.pt` | PyTorch `state_dict` for `HybridClassifier` (~500 MB). |
|
| 13 |
+
| `model.py` | Self-contained architecture definition + `load_model()` helper. |
|
| 14 |
+
| `features.py` | Self-contained linguistic feature extraction (normalize → chunk → 25 features). |
|
| 15 |
+
| `ling_scaler.pkl` | Fitted training `StandardScaler` used to normalize the 25 features. **Required** for valid predictions. |
|
| 16 |
+
| `example_usage.py` | Runnable end-to-end scoring example (raw text → prediction). |
|
| 17 |
+
|
| 18 |
+
## Install
|
| 19 |
+
|
| 20 |
+
```bash
|
| 21 |
+
pip install torch transformers spacy scikit-learn
|
| 22 |
+
python -m spacy download en_core_web_lg
|
| 23 |
+
```
|
| 24 |
+
|
| 25 |
+
## Quick start
|
| 26 |
+
|
| 27 |
+
Score raw text end-to-end (extraction + normalization + document-level scoring
|
| 28 |
+
are handled for you):
|
| 29 |
+
|
| 30 |
+
```bash
|
| 31 |
+
python example_usage.py --text "Your transcript goes here."
|
| 32 |
+
python example_usage.py --file document.txt
|
| 33 |
+
```
|
| 34 |
+
|
| 35 |
+
Or from Python:
|
| 36 |
+
|
| 37 |
+
```python
|
| 38 |
+
import torch
|
| 39 |
+
from transformers import RobertaTokenizer, GPT2LMHeadModel, GPT2TokenizerFast
|
| 40 |
+
import spacy, pickle
|
| 41 |
+
from model import load_model, CONFIG
|
| 42 |
+
from features import extract_raw_features, prepare_document
|
| 43 |
+
from example_usage import score_text
|
| 44 |
+
|
| 45 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 46 |
+
model = load_model("hybrid_model_best.pt", device=device)
|
| 47 |
+
tokenizer = RobertaTokenizer.from_pretrained(CONFIG["roberta_model"])
|
| 48 |
+
nlp = spacy.load("en_core_web_lg", disable=["ner"])
|
| 49 |
+
gpt2_tokenizer = GPT2TokenizerFast.from_pretrained("gpt2")
|
| 50 |
+
gpt2_model = GPT2LMHeadModel.from_pretrained("gpt2").to(device).eval()
|
| 51 |
+
scaler = pickle.load(open("ling_scaler.pkl", "rb"))
|
| 52 |
+
|
| 53 |
+
p_llm, chunk_probs = score_text(
|
| 54 |
+
"Your transcript goes here.",
|
| 55 |
+
model, tokenizer, nlp, gpt2_model, gpt2_tokenizer, scaler, device=device,
|
| 56 |
+
)
|
| 57 |
+
print(p_llm, "->", "machine" if p_llm >= 0.5 else "human")
|
| 58 |
+
```
|
| 59 |
+
|
| 60 |
+
## Linguistic features
|
| 61 |
+
|
| 62 |
+
The 25 features, in the exact order the model expects, are extracted by
|
| 63 |
+
`features.py` and normalized with the fitted training scaler (`ling_scaler.pkl`):
|
| 64 |
+
|
| 65 |
+
```
|
| 66 |
+
msttr, avg_word_len, hapax_ratio, function_ratio, punct_density, char_entropy,
|
| 67 |
+
burstiness, repetition_ratio, avg_sent_len, sent_len_std, noun_ratio,
|
| 68 |
+
verb_ratio, adj_ratio, adv_ratio, pron_ratio, pos_diversity, avg_tree_depth,
|
| 69 |
+
max_tree_depth, sub_clause_ratio, dm_density, sent_len_cv, fp_ratio,
|
| 70 |
+
num_sentences, words_per_sent, perplexity
|
| 71 |
+
```
|
| 72 |
+
|
| 73 |
+
`features.py` reproduces the training/testing pipeline exactly: `normalize_text`
|
| 74 |
+
→ `sliding_window_chunk` (450-word windows, 350-word stride) → 24 spaCy features
|
| 75 |
+
+ GPT-2 perplexity → `StandardScaler.transform`. Document-level scores are the
|
| 76 |
+
mean of per-chunk `P(machine)`. The original notebooks
|
| 77 |
+
(`01_data_preprocessing_v2.py`, `02_feature_extraction.py`) are at
|
| 78 |
+
https://github.com/foggyghost0/Hybrid-Machine-Learning-Approaches-for-Detection-of-LLM-Generated-Texts .
|
| 79 |
+
|
| 80 |
+
## Notes
|
| 81 |
+
|
| 82 |
+
- The checkpoint is a plain `state_dict`; load it with the `HybridClassifier`
|
| 83 |
+
in `model.py` (see `load_model()`).
|
| 84 |
+
- Long documents were chunked to 512 tokens at training time; the reported
|
| 85 |
+
document-level score is the mean chunk probability per document.
|
| 86 |
+
- `return_gate=True` in `forward()` also returns the fusion gate values and the
|
| 87 |
+
feature-attention weights for interpretability.
|
example_usage.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Runnable end-to-end example: score raw text with the hybrid detector.
|
| 2 |
+
|
| 3 |
+
Unlike a placeholder demo, this extracts the 25 linguistic features *exactly* the
|
| 4 |
+
way they were produced for training/testing, normalizes them with the fitted
|
| 5 |
+
training scaler (``ling_scaler.pkl``), and aggregates chunk probabilities into a
|
| 6 |
+
document-level score (mean chunk probability), which is how the thesis reports
|
| 7 |
+
per-document predictions.
|
| 8 |
+
|
| 9 |
+
Pipeline (see features.py for details):
|
| 10 |
+
raw text -> normalize -> sliding-window chunks -> per-chunk 25 features
|
| 11 |
+
-> StandardScaler.transform -> model -> mean chunk P(machine)
|
| 12 |
+
|
| 13 |
+
Requirements:
|
| 14 |
+
pip install torch transformers spacy scikit-learn
|
| 15 |
+
python -m spacy download en_core_web_lg
|
| 16 |
+
|
| 17 |
+
Run:
|
| 18 |
+
python example_usage.py
|
| 19 |
+
python example_usage.py --text "Some text to classify..."
|
| 20 |
+
python example_usage.py --file path/to/document.txt
|
| 21 |
+
"""
|
| 22 |
+
import argparse
|
| 23 |
+
import pickle
|
| 24 |
+
|
| 25 |
+
import numpy as np
|
| 26 |
+
import torch
|
| 27 |
+
import torch.nn.functional as F
|
| 28 |
+
from transformers import (
|
| 29 |
+
GPT2LMHeadModel,
|
| 30 |
+
GPT2TokenizerFast,
|
| 31 |
+
RobertaTokenizer,
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
from model import load_model, CONFIG, LING_FEATURE_NAMES
|
| 35 |
+
from features import extract_raw_features, prepare_document, FEATURE_NAMES
|
| 36 |
+
|
| 37 |
+
# Sanity check: features.py and model.py must agree on feature order.
|
| 38 |
+
assert FEATURE_NAMES == LING_FEATURE_NAMES, "Feature order mismatch."
|
| 39 |
+
|
| 40 |
+
DEFAULT_TEXT = (
|
| 41 |
+
"This is an example transcript to classify as human- or machine-written. "
|
| 42 |
+
"It is deliberately short, so it forms a single chunk. Provide your own "
|
| 43 |
+
"longer document via --text or --file to see multi-chunk aggregation."
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def load_components(device):
|
| 48 |
+
"""Load every model/resource needed to reproduce the test-time pipeline."""
|
| 49 |
+
print("Loading hybrid model ...")
|
| 50 |
+
model = load_model("hybrid_model_best.pt", device=device)
|
| 51 |
+
|
| 52 |
+
print("Loading RoBERTa tokenizer ...")
|
| 53 |
+
tokenizer = RobertaTokenizer.from_pretrained(CONFIG["roberta_model"])
|
| 54 |
+
|
| 55 |
+
print("Loading spaCy (en_core_web_lg) ...")
|
| 56 |
+
import spacy
|
| 57 |
+
try:
|
| 58 |
+
nlp = spacy.load("en_core_web_lg", disable=["ner"])
|
| 59 |
+
except OSError:
|
| 60 |
+
print(" en_core_web_lg not found -> downloading ...")
|
| 61 |
+
spacy.cli.download("en_core_web_lg")
|
| 62 |
+
nlp = spacy.load("en_core_web_lg", disable=["ner"])
|
| 63 |
+
|
| 64 |
+
print("Loading GPT-2 (perplexity) ...")
|
| 65 |
+
gpt2_tokenizer = GPT2TokenizerFast.from_pretrained("gpt2")
|
| 66 |
+
gpt2_model = GPT2LMHeadModel.from_pretrained("gpt2").to(device)
|
| 67 |
+
gpt2_model.eval()
|
| 68 |
+
|
| 69 |
+
print("Loading training feature scaler (ling_scaler.pkl) ...")
|
| 70 |
+
with open("ling_scaler.pkl", "rb") as f:
|
| 71 |
+
scaler = pickle.load(f)
|
| 72 |
+
|
| 73 |
+
return model, tokenizer, nlp, gpt2_model, gpt2_tokenizer, scaler
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def score_text(text, model, tokenizer, nlp, gpt2_model, gpt2_tokenizer, scaler, device):
|
| 77 |
+
"""Return (doc_prob, per_chunk_probs) for a raw document."""
|
| 78 |
+
chunks = prepare_document(text)
|
| 79 |
+
if not chunks:
|
| 80 |
+
raise ValueError("Text is empty after normalization; nothing to score.")
|
| 81 |
+
|
| 82 |
+
chunk_probs = []
|
| 83 |
+
for chunk in chunks:
|
| 84 |
+
# 1. Extract the 25 raw features exactly as during training.
|
| 85 |
+
raw = extract_raw_features(chunk, nlp, gpt2_model, gpt2_tokenizer, device)
|
| 86 |
+
# 2. Normalize with the fitted training scaler.
|
| 87 |
+
norm = scaler.transform(raw.reshape(1, -1)).astype(np.float32)
|
| 88 |
+
ling = torch.from_numpy(norm).to(device)
|
| 89 |
+
# 3. Tokenize text for RoBERTa (same 512-token truncation as training).
|
| 90 |
+
enc = tokenizer(chunk, max_length=512, truncation=True, return_tensors="pt")
|
| 91 |
+
input_ids = enc["input_ids"].to(device)
|
| 92 |
+
attention_mask = enc["attention_mask"].to(device)
|
| 93 |
+
# 4. Forward pass -> P(machine) for this chunk.
|
| 94 |
+
with torch.no_grad():
|
| 95 |
+
logits = model(input_ids, attention_mask, ling)
|
| 96 |
+
p_llm = F.softmax(logits, dim=1)[0, 1].item()
|
| 97 |
+
chunk_probs.append(p_llm)
|
| 98 |
+
|
| 99 |
+
doc_prob = float(np.mean(chunk_probs))
|
| 100 |
+
return doc_prob, chunk_probs
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def main():
|
| 104 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 105 |
+
src = parser.add_mutually_exclusive_group()
|
| 106 |
+
src.add_argument("--text", type=str, help="Raw text to classify.")
|
| 107 |
+
src.add_argument("--file", type=str, help="Path to a UTF-8 text file to classify.")
|
| 108 |
+
args = parser.parse_args()
|
| 109 |
+
|
| 110 |
+
if args.file:
|
| 111 |
+
with open(args.file, "r", encoding="utf-8") as f:
|
| 112 |
+
text = f.read()
|
| 113 |
+
elif args.text:
|
| 114 |
+
text = args.text
|
| 115 |
+
else:
|
| 116 |
+
text = DEFAULT_TEXT
|
| 117 |
+
|
| 118 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 119 |
+
print(f"Device: {device}\n")
|
| 120 |
+
|
| 121 |
+
components = load_components(device)
|
| 122 |
+
doc_prob, chunk_probs = score_text(text, *components, device=device)
|
| 123 |
+
|
| 124 |
+
print("\n" + "=" * 60)
|
| 125 |
+
print(f"Chunks scored: {len(chunk_probs)}")
|
| 126 |
+
for i, p in enumerate(chunk_probs):
|
| 127 |
+
print(f" chunk {i:>2}: P(machine) = {p:.4f}")
|
| 128 |
+
print("-" * 60)
|
| 129 |
+
print(f"Document P(machine-generated) = {doc_prob:.4f}")
|
| 130 |
+
print(f"Prediction: {'machine-generated' if doc_prob >= 0.5 else 'human-written'}")
|
| 131 |
+
print("=" * 60)
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
if __name__ == "__main__":
|
| 135 |
+
main()
|
features.py
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Self-contained linguistic feature extraction for the hybrid detector.
|
| 2 |
+
|
| 3 |
+
This reproduces *exactly* the preprocessing + feature pipeline used to train and
|
| 4 |
+
evaluate the released ``hybrid_model_best.pt`` checkpoint, so that a raw piece of
|
| 5 |
+
text can be scored the same way it was during testing:
|
| 6 |
+
|
| 7 |
+
raw text
|
| 8 |
+
-> normalize_text() (lowercase, strip HTML/markdown, collapse ws)
|
| 9 |
+
-> sliding_window_chunk() (450-word windows, 350-word stride)
|
| 10 |
+
-> extract_raw_features() (24 spaCy features + 1 GPT-2 perplexity = 25)
|
| 11 |
+
-> StandardScaler.transform() (the fitted training scaler, ling_scaler.pkl)
|
| 12 |
+
-> model(...) (per chunk)
|
| 13 |
+
-> mean chunk probability (document-level score)
|
| 14 |
+
|
| 15 |
+
The 25 features, in order, match ``model.LING_FEATURE_NAMES``:
|
| 16 |
+
|
| 17 |
+
msttr, avg_word_len, hapax_ratio, function_ratio, punct_density, char_entropy,
|
| 18 |
+
burstiness, repetition_ratio, avg_sent_len, sent_len_std, noun_ratio,
|
| 19 |
+
verb_ratio, adj_ratio, adv_ratio, pron_ratio, pos_diversity, avg_tree_depth,
|
| 20 |
+
max_tree_depth, sub_clause_ratio, dm_density, sent_len_cv, fp_ratio,
|
| 21 |
+
num_sentences, words_per_sent, perplexity
|
| 22 |
+
|
| 23 |
+
Requirements:
|
| 24 |
+
pip install torch transformers spacy scikit-learn
|
| 25 |
+
python -m spacy download en_core_web_lg
|
| 26 |
+
"""
|
| 27 |
+
import math
|
| 28 |
+
import re
|
| 29 |
+
from collections import Counter
|
| 30 |
+
|
| 31 |
+
import numpy as np
|
| 32 |
+
import torch
|
| 33 |
+
|
| 34 |
+
# --------------------------------------------------------------------------- #
|
| 35 |
+
# Feature order (must match model.LING_FEATURE_NAMES).
|
| 36 |
+
# --------------------------------------------------------------------------- #
|
| 37 |
+
FEATURE_NAMES = [
|
| 38 |
+
"msttr", "avg_word_len", "hapax_ratio", "function_ratio", "punct_density",
|
| 39 |
+
"char_entropy", "burstiness", "repetition_ratio",
|
| 40 |
+
"avg_sent_len", "sent_len_std", "noun_ratio", "verb_ratio", "adj_ratio",
|
| 41 |
+
"adv_ratio", "pron_ratio", "pos_diversity", "avg_tree_depth",
|
| 42 |
+
"max_tree_depth", "sub_clause_ratio",
|
| 43 |
+
"dm_density", "sent_len_cv", "fp_ratio", "num_sentences", "words_per_sent",
|
| 44 |
+
"perplexity",
|
| 45 |
+
]
|
| 46 |
+
|
| 47 |
+
# --------------------------------------------------------------------------- #
|
| 48 |
+
# Preprocessing (mirrors 01_data_preprocessing_v2.py).
|
| 49 |
+
# --------------------------------------------------------------------------- #
|
| 50 |
+
WINDOW_SIZE = 450
|
| 51 |
+
STRIDE = 350
|
| 52 |
+
MIN_WINDOW_TOKENS = 50
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def normalize_text(text: str) -> str:
|
| 56 |
+
"""Light normalization matching the training preprocessing.
|
| 57 |
+
|
| 58 |
+
Lowercase, strip HTML/markdown artifacts, normalize quotes, collapse
|
| 59 |
+
whitespace. Applied to every document before chunking / feature extraction.
|
| 60 |
+
"""
|
| 61 |
+
if not text or not isinstance(text, str):
|
| 62 |
+
return ""
|
| 63 |
+
|
| 64 |
+
text = text.lower()
|
| 65 |
+
text = re.sub(r"<[^>]+>", " ", text)
|
| 66 |
+
text = re.sub(r"#{1,6}\s+", " ", text)
|
| 67 |
+
text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text)
|
| 68 |
+
text = re.sub(r"!\[[^\]]+\]\([^)]+\)", " ", text)
|
| 69 |
+
text = text.replace('"', '"').replace('"', '"')
|
| 70 |
+
text = text.replace("'", "'").replace("'", "'")
|
| 71 |
+
text = re.sub(r"\s+", " ", text).strip()
|
| 72 |
+
return text
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def sliding_window_chunk(text, window_size=WINDOW_SIZE, stride=STRIDE):
|
| 76 |
+
"""Split text into overlapping word windows (same as training)."""
|
| 77 |
+
if not text:
|
| 78 |
+
return []
|
| 79 |
+
|
| 80 |
+
words = text.split()
|
| 81 |
+
total_words = len(words)
|
| 82 |
+
|
| 83 |
+
if total_words <= window_size:
|
| 84 |
+
return [text]
|
| 85 |
+
|
| 86 |
+
chunks = []
|
| 87 |
+
start = 0
|
| 88 |
+
while start < total_words:
|
| 89 |
+
end = min(start + window_size, total_words)
|
| 90 |
+
chunk_words = words[start:end]
|
| 91 |
+
if len(chunk_words) >= MIN_WINDOW_TOKENS:
|
| 92 |
+
chunks.append(" ".join(chunk_words))
|
| 93 |
+
start += stride
|
| 94 |
+
if end == total_words:
|
| 95 |
+
break
|
| 96 |
+
return chunks
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
# --------------------------------------------------------------------------- #
|
| 100 |
+
# Lexicons (identical to 02_feature_extraction.py).
|
| 101 |
+
# --------------------------------------------------------------------------- #
|
| 102 |
+
DISCOURSE_MARKERS = {
|
| 103 |
+
"however", "therefore", "moreover", "furthermore", "nevertheless",
|
| 104 |
+
"consequently", "meanwhile", "additionally", "similarly", "likewise",
|
| 105 |
+
"thus", "hence", "accordingly", "otherwise", "instead",
|
| 106 |
+
"first", "second", "third", "finally", "next", "then",
|
| 107 |
+
"in conclusion", "in summary", "to summarize", "overall",
|
| 108 |
+
"for example", "for instance", "specifically", "in particular",
|
| 109 |
+
"on the other hand", "in contrast", "conversely", "although",
|
| 110 |
+
"because", "since", "while", "whereas", "unless", "if",
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
FUNCTION_WORDS = {
|
| 114 |
+
"the", "a", "an", "and", "or", "but", "if", "then", "else",
|
| 115 |
+
"when", "where", "how", "what", "who", "which", "that", "this",
|
| 116 |
+
"is", "are", "was", "were", "be", "been", "being",
|
| 117 |
+
"have", "has", "had", "do", "does", "did",
|
| 118 |
+
"will", "would", "could", "should", "may", "might", "must",
|
| 119 |
+
"to", "of", "in", "for", "on", "with", "at", "by", "from",
|
| 120 |
+
"as", "into", "through", "during", "before", "after",
|
| 121 |
+
"above", "below", "between", "under", "over",
|
| 122 |
+
"i", "you", "he", "she", "it", "we", "they", "me", "him", "her", "us", "them",
|
| 123 |
+
"my", "your", "his", "her", "its", "our", "their",
|
| 124 |
+
"not", "no", "yes", "so", "very", "just", "also", "only",
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
SUB_MARKERS = {
|
| 128 |
+
"that", "which", "who", "whom", "whose", "where", "when", "while",
|
| 129 |
+
"because", "although", "if", "unless",
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
FIRST_PERSON = {
|
| 133 |
+
"i", "me", "my", "mine", "myself", "we", "us", "our", "ours", "ourselves",
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
# --------------------------------------------------------------------------- #
|
| 138 |
+
# Feature helpers (identical to 02_feature_extraction.py).
|
| 139 |
+
# --------------------------------------------------------------------------- #
|
| 140 |
+
def get_tree_depth(token):
|
| 141 |
+
depth = 0
|
| 142 |
+
current = token
|
| 143 |
+
while current.head != current:
|
| 144 |
+
depth += 1
|
| 145 |
+
current = current.head
|
| 146 |
+
if depth > 100:
|
| 147 |
+
break
|
| 148 |
+
return depth
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def calculate_entropy(text):
|
| 152 |
+
if not text:
|
| 153 |
+
return 0.0
|
| 154 |
+
counts = Counter(text)
|
| 155 |
+
total = len(text)
|
| 156 |
+
probs = [c / total for c in counts.values()]
|
| 157 |
+
return -sum(p * math.log2(p) for p in probs)
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def calculate_burstiness(words):
|
| 161 |
+
if not words:
|
| 162 |
+
return 0.0
|
| 163 |
+
word_counts = list(Counter(words).values())
|
| 164 |
+
if not word_counts:
|
| 165 |
+
return 0.0
|
| 166 |
+
return np.std(word_counts) / np.mean(word_counts) if np.mean(word_counts) > 0 else 0.0
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def calculate_repetition_ratio(words):
|
| 170 |
+
if not words:
|
| 171 |
+
return 0.0
|
| 172 |
+
counts = Counter(words)
|
| 173 |
+
repeated = sum(c for w, c in counts.items() if c > 1)
|
| 174 |
+
return repeated / len(words)
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def calculate_msttr(words, window_size=50):
|
| 178 |
+
if not words:
|
| 179 |
+
return 0.0
|
| 180 |
+
if len(words) < window_size:
|
| 181 |
+
return len(set(words)) / len(words)
|
| 182 |
+
ttrs = []
|
| 183 |
+
for i in range(0, len(words), window_size):
|
| 184 |
+
segment = words[i:i + window_size]
|
| 185 |
+
if len(segment) == window_size:
|
| 186 |
+
ttrs.append(len(set(segment)) / len(segment))
|
| 187 |
+
return np.mean(ttrs) if ttrs else 0.0
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def calculate_pos_diversity(doc):
|
| 191 |
+
pos_counts = Counter([token.pos_ for token in doc])
|
| 192 |
+
total = len(doc)
|
| 193 |
+
if total == 0:
|
| 194 |
+
return 0.0
|
| 195 |
+
probs = [count / total for count in pos_counts.values()]
|
| 196 |
+
return -sum(p * math.log2(p) for p in probs)
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def calculate_perplexity(text, model, tokenizer, device):
|
| 200 |
+
"""GPT-2 perplexity (single truncated window, matching training)."""
|
| 201 |
+
max_length = model.config.n_positions
|
| 202 |
+
encodings = tokenizer(text, return_tensors="pt", truncation=True, max_length=max_length)
|
| 203 |
+
seq_len = encodings.input_ids.size(1)
|
| 204 |
+
if seq_len < 2:
|
| 205 |
+
return 0.0
|
| 206 |
+
input_ids = encodings.input_ids.to(device)
|
| 207 |
+
with torch.no_grad():
|
| 208 |
+
outputs = model(input_ids, labels=input_ids)
|
| 209 |
+
neg_log_likelihood = outputs.loss
|
| 210 |
+
if neg_log_likelihood is None:
|
| 211 |
+
return 0.0
|
| 212 |
+
return torch.exp(neg_log_likelihood).item()
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def _cpu_features_from_doc(doc):
|
| 216 |
+
"""The 24 spaCy-based features for a single spaCy Doc."""
|
| 217 |
+
text = doc.text
|
| 218 |
+
words = [token.text.lower() for token in doc if token.is_alpha]
|
| 219 |
+
sentences = list(doc.sents)
|
| 220 |
+
|
| 221 |
+
features = {}
|
| 222 |
+
if len(words) == 0:
|
| 223 |
+
return {}
|
| 224 |
+
|
| 225 |
+
features["msttr"] = calculate_msttr(words)
|
| 226 |
+
features["avg_word_len"] = np.mean([len(w) for w in words]) if words else 0
|
| 227 |
+
|
| 228 |
+
word_counts = Counter(words)
|
| 229 |
+
hapax = sum(1 for w, c in word_counts.items() if c == 1)
|
| 230 |
+
features["hapax_ratio"] = hapax / len(words) if words else 0
|
| 231 |
+
|
| 232 |
+
function_count = sum(1 for w in words if w in FUNCTION_WORDS)
|
| 233 |
+
features["function_ratio"] = function_count / len(words) if words else 0
|
| 234 |
+
|
| 235 |
+
punct_count = sum(1 for token in doc if token.is_punct)
|
| 236 |
+
features["punct_density"] = (punct_count / len(words)) * 100 if words else 0
|
| 237 |
+
|
| 238 |
+
features["char_entropy"] = calculate_entropy(text)
|
| 239 |
+
features["burstiness"] = calculate_burstiness(words)
|
| 240 |
+
features["repetition_ratio"] = calculate_repetition_ratio(words)
|
| 241 |
+
|
| 242 |
+
sent_lengths = [len([t for t in sent if t.is_alpha]) for sent in sentences]
|
| 243 |
+
features["avg_sent_len"] = np.mean(sent_lengths) if sent_lengths else 0
|
| 244 |
+
features["sent_len_std"] = np.std(sent_lengths) if len(sent_lengths) > 1 else 0
|
| 245 |
+
|
| 246 |
+
pos_counts = Counter([token.pos_ for token in doc])
|
| 247 |
+
total_tokens = len(doc)
|
| 248 |
+
features["noun_ratio"] = pos_counts.get("NOUN", 0) / total_tokens if total_tokens else 0
|
| 249 |
+
features["verb_ratio"] = pos_counts.get("VERB", 0) / total_tokens if total_tokens else 0
|
| 250 |
+
features["adj_ratio"] = pos_counts.get("ADJ", 0) / total_tokens if total_tokens else 0
|
| 251 |
+
features["adv_ratio"] = pos_counts.get("ADV", 0) / total_tokens if total_tokens else 0
|
| 252 |
+
features["pron_ratio"] = pos_counts.get("PRON", 0) / total_tokens if total_tokens else 0
|
| 253 |
+
|
| 254 |
+
features["pos_diversity"] = calculate_pos_diversity(doc)
|
| 255 |
+
|
| 256 |
+
depths = [get_tree_depth(token) for token in doc if token.dep_ != "punct"]
|
| 257 |
+
features["avg_tree_depth"] = np.mean(depths) if depths else 0
|
| 258 |
+
features["max_tree_depth"] = max(depths) if depths else 0
|
| 259 |
+
|
| 260 |
+
sub_count = sum(1 for token in doc if token.text.lower() in SUB_MARKERS)
|
| 261 |
+
features["sub_clause_ratio"] = sub_count / len(sentences) if sentences else 0
|
| 262 |
+
|
| 263 |
+
text_lower = text.lower()
|
| 264 |
+
dm_count = sum(1 for dm in DISCOURSE_MARKERS if dm in text_lower)
|
| 265 |
+
features["dm_density"] = (dm_count / len(words)) * 100 if words else 0
|
| 266 |
+
|
| 267 |
+
features["sent_len_cv"] = (
|
| 268 |
+
features["sent_len_std"] / features["avg_sent_len"]
|
| 269 |
+
if features["avg_sent_len"] > 0 else 0
|
| 270 |
+
)
|
| 271 |
+
|
| 272 |
+
fp_count = sum(1 for w in words if w in FIRST_PERSON)
|
| 273 |
+
features["fp_ratio"] = fp_count / len(words) if words else 0
|
| 274 |
+
|
| 275 |
+
features["num_sentences"] = len(sentences)
|
| 276 |
+
features["words_per_sent"] = len(words) / len(sentences) if sentences else 0
|
| 277 |
+
|
| 278 |
+
return features
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
def extract_raw_features(text, nlp, gpt2_model, gpt2_tokenizer, device):
|
| 282 |
+
"""Return the 25-dim *raw* (un-normalized) feature vector for one chunk.
|
| 283 |
+
|
| 284 |
+
``text`` is expected to be a single normalized chunk (see normalize_text /
|
| 285 |
+
sliding_window_chunk). Order matches FEATURE_NAMES.
|
| 286 |
+
"""
|
| 287 |
+
doc = nlp(text)
|
| 288 |
+
feats = _cpu_features_from_doc(doc)
|
| 289 |
+
ppl = calculate_perplexity(text, gpt2_model, gpt2_tokenizer, device)
|
| 290 |
+
|
| 291 |
+
vec = []
|
| 292 |
+
for name in FEATURE_NAMES:
|
| 293 |
+
if name == "perplexity":
|
| 294 |
+
vec.append(ppl)
|
| 295 |
+
else:
|
| 296 |
+
vec.append(feats.get(name, 0.0))
|
| 297 |
+
return np.array(vec, dtype=np.float32)
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
def prepare_document(text):
|
| 301 |
+
"""Normalize a raw document and split it into the chunks used at test time."""
|
| 302 |
+
return sliding_window_chunk(normalize_text(text))
|
hybrid_model_best.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:f2d8dae1640731018bdae408478a118f80b83eb36d626c9a6491d7035732dc54
|
| 3 |
+
size 506593731
|
ling_scaler.pkl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:07bc0594c10eab762389550c377296c28cd6d8c8785fc4b7dc9742fd80c7c877
|
| 3 |
+
size 1050
|
model.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Self-contained definition of the hybrid RoBERTa + linguistic-features model.
|
| 2 |
+
|
| 3 |
+
The released checkpoint ``hybrid_model_best.pt`` is a plain PyTorch
|
| 4 |
+
``state_dict`` for the ``HybridClassifier`` defined here. Import this module to
|
| 5 |
+
rebuild the architecture and load the weights.
|
| 6 |
+
"""
|
| 7 |
+
import torch
|
| 8 |
+
import torch.nn as nn
|
| 9 |
+
from transformers import RobertaModel
|
| 10 |
+
|
| 11 |
+
CONFIG = {
|
| 12 |
+
"roberta_model": "roberta-base",
|
| 13 |
+
"roberta_dim": 768,
|
| 14 |
+
"ling_dim": 25,
|
| 15 |
+
"ling_hidden": 256,
|
| 16 |
+
"hidden_dim": 768,
|
| 17 |
+
"dropout": 0.15,
|
| 18 |
+
"freeze_bottom_layers": 6,
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
LING_FEATURE_NAMES = [
|
| 22 |
+
"msttr", "avg_word_len", "hapax_ratio", "function_ratio", "punct_density",
|
| 23 |
+
"char_entropy", "burstiness", "repetition_ratio", "avg_sent_len",
|
| 24 |
+
"sent_len_std", "noun_ratio", "verb_ratio", "adj_ratio", "adv_ratio",
|
| 25 |
+
"pron_ratio", "pos_diversity", "avg_tree_depth", "max_tree_depth",
|
| 26 |
+
"sub_clause_ratio", "dm_density", "sent_len_cv", "fp_ratio",
|
| 27 |
+
"num_sentences", "words_per_sent", "perplexity",
|
| 28 |
+
]
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class FeatureAttention(nn.Module):
|
| 32 |
+
def __init__(self, num_features):
|
| 33 |
+
super().__init__()
|
| 34 |
+
self.attention = nn.Sequential(
|
| 35 |
+
nn.Linear(num_features, num_features),
|
| 36 |
+
nn.Tanh(),
|
| 37 |
+
nn.Linear(num_features, num_features),
|
| 38 |
+
nn.Softmax(dim=-1),
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
def forward(self, features):
|
| 42 |
+
attn_weights = self.attention(features)
|
| 43 |
+
weighted_features = features * attn_weights
|
| 44 |
+
return weighted_features, attn_weights
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class GatedFusion(nn.Module):
|
| 48 |
+
def __init__(self, dim):
|
| 49 |
+
super().__init__()
|
| 50 |
+
self.gate = nn.Sequential(
|
| 51 |
+
nn.Linear(dim * 2, dim),
|
| 52 |
+
nn.Sigmoid(),
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
def forward(self, cls_emb, ling_emb):
|
| 56 |
+
combined = torch.cat([cls_emb, ling_emb], dim=-1)
|
| 57 |
+
g = self.gate(combined)
|
| 58 |
+
fused = g * cls_emb + (1 - g) * ling_emb
|
| 59 |
+
return fused, g
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class HybridClassifier(nn.Module):
|
| 63 |
+
"""RoBERTa CLS embedding gated-fused with attended linguistic features."""
|
| 64 |
+
|
| 65 |
+
def __init__(self, config=CONFIG):
|
| 66 |
+
super().__init__()
|
| 67 |
+
self.roberta = RobertaModel.from_pretrained(config["roberta_model"])
|
| 68 |
+
if config["freeze_bottom_layers"] > 0:
|
| 69 |
+
for param in self.roberta.embeddings.parameters():
|
| 70 |
+
param.requires_grad = False
|
| 71 |
+
for i in range(config["freeze_bottom_layers"]):
|
| 72 |
+
for param in self.roberta.encoder.layer[i].parameters():
|
| 73 |
+
param.requires_grad = False
|
| 74 |
+
self.feature_attention = FeatureAttention(config["ling_dim"])
|
| 75 |
+
self.ling_projection = nn.Sequential(
|
| 76 |
+
nn.Linear(config["ling_dim"], config["ling_hidden"]),
|
| 77 |
+
nn.LayerNorm(config["ling_hidden"]),
|
| 78 |
+
nn.GELU(),
|
| 79 |
+
nn.Dropout(config["dropout"]),
|
| 80 |
+
nn.Linear(config["ling_hidden"], config["roberta_dim"]),
|
| 81 |
+
nn.LayerNorm(config["roberta_dim"]),
|
| 82 |
+
nn.GELU(),
|
| 83 |
+
nn.Dropout(config["dropout"]),
|
| 84 |
+
)
|
| 85 |
+
self.gated_fusion = GatedFusion(config["roberta_dim"])
|
| 86 |
+
self.classifier = nn.Sequential(
|
| 87 |
+
nn.Linear(config["roberta_dim"], config["hidden_dim"]),
|
| 88 |
+
nn.GELU(),
|
| 89 |
+
nn.Dropout(config["dropout"]),
|
| 90 |
+
nn.Linear(config["hidden_dim"], 2),
|
| 91 |
+
)
|
| 92 |
+
self.dropout = nn.Dropout(config["dropout"])
|
| 93 |
+
self.config = config
|
| 94 |
+
|
| 95 |
+
def forward(self, input_ids, attention_mask, ling_features, return_gate=False):
|
| 96 |
+
outputs = self.roberta(input_ids=input_ids, attention_mask=attention_mask)
|
| 97 |
+
cls_embedding = outputs.last_hidden_state[:, 0, :]
|
| 98 |
+
cls_embedding = self.dropout(cls_embedding)
|
| 99 |
+
attended_features, attn_weights = self.feature_attention(ling_features)
|
| 100 |
+
ling_proj = self.ling_projection(attended_features)
|
| 101 |
+
fused, gate_values = self.gated_fusion(cls_embedding, ling_proj)
|
| 102 |
+
logits = self.classifier(fused)
|
| 103 |
+
if return_gate:
|
| 104 |
+
return logits, gate_values, attn_weights
|
| 105 |
+
return logits
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def load_model(checkpoint_path="hybrid_model_best.pt", device="cpu"):
|
| 109 |
+
"""Build the model and load the released weights. Returns an eval-mode model."""
|
| 110 |
+
model = HybridClassifier(CONFIG)
|
| 111 |
+
state_dict = torch.load(checkpoint_path, map_location=device)
|
| 112 |
+
model.load_state_dict(state_dict)
|
| 113 |
+
model.to(device)
|
| 114 |
+
model.eval()
|
| 115 |
+
return model
|