foggughost0 commited on
Commit
449aff1
·
verified ·
1 Parent(s): e3f95d5

Upload 6 files

Browse files
Files changed (6) hide show
  1. README.md +87 -3
  2. example_usage.py +135 -0
  3. features.py +302 -0
  4. hybrid_model_best.pt +3 -0
  5. ling_scaler.pkl +3 -0
  6. model.py +115 -0
README.md CHANGED
@@ -1,3 +1,87 @@
1
- ---
2
- license: apache-2.0
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