batmac commited on
Commit
bce5fe2
·
verified ·
1 Parent(s): 4c0e3b7

Add AI text detector Gradio app on CPU

Browse files
Files changed (3) hide show
  1. README.md +45 -6
  2. app.py +393 -0
  3. requirements.txt +9 -0
README.md CHANGED
@@ -1,13 +1,52 @@
1
  ---
2
- title: Ai Text Detector
3
- emoji: 🔥
4
- colorFrom: blue
5
- colorTo: purple
6
  sdk: gradio
7
  sdk_version: 6.27.0
8
- python_version: '3.12'
9
  app_file: app.py
10
  pinned: false
 
 
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: AI Text Detector
3
+ emoji: 🔍
4
+ colorFrom: gray
5
+ colorTo: red
6
  sdk: gradio
7
  sdk_version: 6.27.0
 
8
  app_file: app.py
9
  pinned: false
10
+ license: mit
11
+ models:
12
+ - batmac/gradient-ai-text-detector-4bit
13
+ short_description: Estimate whether text was written by a language model
14
  ---
15
 
16
+ # AI Text Detector
17
+
18
+ Paste text to estimate the probability it was written by a language model, using
19
+ [`batmac/gradient-ai-text-detector-4bit`](https://huggingface.co/batmac/gradient-ai-text-detector-4bit),
20
+ a 4-bit NF4 quantization of
21
+ [`ShantanuT01/gradient-ai-text-detector`](https://huggingface.co/ShantanuT01/gradient-ai-text-detector)
22
+ (DeBERTa-v3-large, single sigmoid head producing P(AI)).
23
+
24
+ ## What it reports
25
+
26
+ - The input text with a green-to-red background per sentence
27
+ - An overall score, the mean and max across windows, and a verdict
28
+ - Sentence-level scores, plus the top AI-like passages
29
+ - A JSON summary with the score, threshold, aggregation, and every sentence score
30
+
31
+ ## Controls
32
+
33
+ **Decision threshold**, 0.50 to 0.99, default 0.50. Upstream documents only the
34
+ 0.5 cut, so the default reproduces it; raising the slider trades false positives
35
+ for misses. No false-positive rate is claimed for any other value.
36
+
37
+ **Window aggregation**, `mean` or `max`. Text longer than 512 tokens is split
38
+ into windows; `mean` averages them and `max` reports the highest-scoring one. They
39
+ are identical for text that fits a single window (~2,500 characters of English).
40
+
41
+ Text is capped at 20,000 characters, and inference runs on CPU, so a shared Space
42
+ processes one request at a time.
43
+
44
+ ## Caveats
45
+
46
+ Scores are model probabilities, not proof. The model card warns against using
47
+ this as the sole basis for high-stakes decisions such as academic or employment
48
+ penalties, and notes degraded accuracy on text distributions unlike its training
49
+ data. The model authors and Microsoft do not endorse this Space.
50
+
51
+ This Space runs the same `app.py` as the local project that produced the
52
+ quantized checkpoint.
app.py ADDED
@@ -0,0 +1,393 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gradio app for AI-generated text detection.
2
+
3
+ Scores text with P(AI) in [0, 1] using
4
+ batmac/gradient-ai-text-detector-4bit, a 4-bit NF4 quantization of
5
+ ShantanuT01/gradient-ai-text-detector (DeBERTa-v3-large, sigmoid head), and
6
+ renders a readable report.
7
+
8
+ The device is configurable with ``AID_DEVICE``: ``auto`` (default, CUDA > MPS >
9
+ CPU), or one of ``cuda``/``mps``/``cpu``. The model itself always comes from the
10
+ Hub repo below.
11
+ """
12
+
13
+ import html
14
+ import os
15
+ import re
16
+ import time
17
+ import warnings
18
+
19
+ # transformers imports torch.jit, which trips a deprecation warning on torch 2.x.
20
+ warnings.filterwarnings(
21
+ "ignore",
22
+ message=r"`torch\.jit\.script` is deprecated.*",
23
+ category=FutureWarning,
24
+ )
25
+
26
+ import gradio as gr
27
+ import torch
28
+ from transformers import AutoConfig, AutoModelForSequenceClassification, AutoTokenizer
29
+
30
+ MODEL_ID = "batmac/gradient-ai-text-detector-4bit"
31
+ MAX_TOKENS = 512
32
+ MAX_CHARS = 20_000
33
+ THRESHOLD = 0.5
34
+ MIN_THRESHOLD = 0.5
35
+ MAX_THRESHOLD = 0.99
36
+ AGGREGATIONS = ("mean", "max")
37
+ BATCH_SIZE = 8
38
+
39
+ DEVICE_PREF = os.environ.get("AID_DEVICE", "auto").lower()
40
+
41
+
42
+ def resolve_device():
43
+ if DEVICE_PREF != "auto":
44
+ return DEVICE_PREF
45
+ if torch.cuda.is_available():
46
+ return "cuda"
47
+ if torch.backends.mps.is_available():
48
+ return "mps"
49
+ return "cpu"
50
+
51
+
52
+ def load_model(device):
53
+ """Load the quantized detector declared by the repo's own config."""
54
+ config = AutoConfig.from_pretrained(MODEL_ID)
55
+ quantization = getattr(config, "quantization_config", None)
56
+ if not quantization:
57
+ raise RuntimeError(
58
+ f"{MODEL_ID} does not declare a quantization_config; it is expected "
59
+ "to be the 4-bit NF4 checkpoint"
60
+ )
61
+ model = AutoModelForSequenceClassification.from_pretrained(
62
+ MODEL_ID, config=config
63
+ )
64
+ model.to(device)
65
+ return model, "nf4 4-bit"
66
+
67
+
68
+ DEVICE = resolve_device()
69
+ STARTED = time.time()
70
+ model, QUANT_LABEL = load_model(DEVICE)
71
+ model.eval()
72
+ LOAD_SECONDS = time.time() - STARTED
73
+ MODEL_MB = model.get_memory_footprint() / 1024**2
74
+
75
+ tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
76
+
77
+
78
+ SPLIT_RE = re.compile(r"(?<=[.!?])\s+|\n{2,}")
79
+
80
+
81
+ def sentence_spans(text):
82
+ """Return (start, end, sentence) triples, preserving source offsets."""
83
+ spans = []
84
+ start = 0
85
+ for match in SPLIT_RE.finditer(text):
86
+ chunk = text[start : match.start()]
87
+ if chunk.strip():
88
+ spans.append((start, match.start(), chunk))
89
+ start = match.end()
90
+ tail = text[start:]
91
+ if tail.strip():
92
+ spans.append((start, len(text), tail))
93
+ return spans
94
+
95
+
96
+ def split_sentences(text):
97
+ return [sentence for _, _, sentence in sentence_spans(text)]
98
+
99
+
100
+ def split_for_model(text):
101
+ """Pack sentences into chunks that fit the model's token window."""
102
+ chunks = []
103
+ current = ""
104
+ for sentence in split_sentences(text):
105
+ candidate = f"{current} {sentence}".strip()
106
+ if current and len(tokenizer(candidate)["input_ids"]) > MAX_TOKENS:
107
+ chunks.append(current)
108
+ current = sentence
109
+ else:
110
+ current = candidate
111
+ if current:
112
+ chunks.append(current)
113
+ return chunks or [text.strip()]
114
+
115
+
116
+ @torch.no_grad()
117
+ def score(texts):
118
+ probs = []
119
+ for start in range(0, len(texts), BATCH_SIZE):
120
+ batch = texts[start : start + BATCH_SIZE]
121
+ encoded = tokenizer(
122
+ batch,
123
+ return_tensors="pt",
124
+ truncation=True,
125
+ max_length=MAX_TOKENS,
126
+ padding=True,
127
+ )
128
+ encoded = {
129
+ key: value.to(next(model.parameters()).device)
130
+ for key, value in encoded.items()
131
+ }
132
+ logits = model(**encoded).logits.squeeze(-1)
133
+ probs.extend(torch.sigmoid(logits).float().cpu().tolist())
134
+ return probs
135
+
136
+
137
+ def verdict(prob, threshold):
138
+ if prob >= max(threshold, 0.9):
139
+ return "AI-generated"
140
+ if prob >= threshold:
141
+ return "Likely AI-generated"
142
+ if prob <= 0.1:
143
+ return "Human-written"
144
+ return "Likely human-written"
145
+
146
+
147
+ def aggregate(probs, mode):
148
+ """Combine per-window scores into one score.
149
+
150
+ ``mean`` averages every window. ``max`` reports the highest-scoring window,
151
+ so one AI-like passage is not diluted by surrounding human text.
152
+ """
153
+ if mode == "max":
154
+ return max(probs)
155
+ return sum(probs) / len(probs)
156
+
157
+
158
+ def bar(prob, width=24):
159
+ filled = round(prob * width)
160
+ return "#" * filled + "-" * (width - filled)
161
+
162
+
163
+ def prob_color(prob):
164
+ """Interpolate a green (P(AI)=0) to red (P(AI)=1) background."""
165
+ hue = 120 * (1 - min(max(prob, 0.0), 1.0))
166
+ return f"hsl({hue:.0f}, 85%, 78%)"
167
+
168
+
169
+ def highlight_html(text, spans, probs):
170
+ """Render the input text with a per-sentence colored background."""
171
+ parts = ['<div class="aid-highlight">']
172
+ cursor = 0
173
+ for (start, end, sentence), prob in zip(spans, probs):
174
+ gap = text[cursor:start]
175
+ parts.append(html.escape(gap).replace("\n", "<br>"))
176
+ color = prob_color(prob)
177
+ parts.append(
178
+ f'<span title="P(AI) = {prob:.3f}" '
179
+ f'style="background:{color}; border-radius:3px; padding:1px 2px;" '
180
+ f'data-p="{prob:.3f}">{html.escape(sentence)}</span>'
181
+ )
182
+ cursor = end
183
+ parts.append(html.escape(text[cursor:]).replace("\n", "<br>"))
184
+ parts.append("</div>")
185
+
186
+ legend = ["<div class='aid-legend'>"]
187
+ for stop in (0.0, 0.25, 0.5, 0.75, 1.0):
188
+ legend.append(
189
+ f'<span style="background:{prob_color(stop)}; border-radius:3px; '
190
+ f'padding:1px 6px;">P(AI) {stop:.2f}</span>'
191
+ )
192
+ legend.append("</div>")
193
+
194
+ return (
195
+ '<style>'
196
+ ".aid-highlight { line-height: 1.9; white-space: normal; }"
197
+ ".aid-legend { margin-top: 10px; display: flex; gap: 8px; flex-wrap: wrap; "
198
+ "font-size: 0.8em; }"
199
+ "</style>"
200
+ + "".join(legend)
201
+ + "".join(parts)
202
+ )
203
+
204
+
205
+ def analyze(text, threshold, aggregation):
206
+ text = (text or "").strip()
207
+ if not text:
208
+ raise gr.Error("Enter some text to analyze.")
209
+ if len(text) > MAX_CHARS:
210
+ raise gr.Error(
211
+ f"Text is {len(text):,} characters; the limit is {MAX_CHARS:,}. "
212
+ "Analyze it in parts."
213
+ )
214
+
215
+ threshold = float(threshold)
216
+ aggregation = aggregation if aggregation in AGGREGATIONS else "mean"
217
+
218
+ spans = sentence_spans(text)
219
+ sentences = [sentence for _, _, sentence in spans]
220
+ chunks = split_for_model(text)
221
+ chunk_probs = score(chunks)
222
+ sentence_probs = score(sentences) if sentences else chunk_probs
223
+ highlighted = highlight_html(text, spans, sentence_probs)
224
+
225
+ overall = aggregate(chunk_probs, aggregation)
226
+ ai_sentences = sum(1 for p in sentence_probs if p >= threshold)
227
+ flagged = sorted(
228
+ zip(sentence_probs, sentences), key=lambda item: item[0], reverse=True
229
+ )[:5]
230
+ agg_label = "Max" if aggregation == "max" else "Mean"
231
+ if len(chunks) == 1:
232
+ agg_note = "one window, so mean and max are identical"
233
+ elif aggregation == "max":
234
+ agg_note = "highest-scoring window"
235
+ else:
236
+ agg_note = f"average across {len(chunks)} windows"
237
+
238
+ lines = [
239
+ "# AI Text Detection Report",
240
+ "",
241
+ f"**Model:** `{MODEL_ID}`",
242
+ f"**Backend:** {QUANT_LABEL} on {DEVICE} | {MODEL_MB:,.0f} MB resident "
243
+ f"| loaded in {LOAD_SECONDS:.1f} s",
244
+ f"**Characters:** {len(text):,} | **Sentences:** {len(sentences):,}"
245
+ f" | **Windows scored:** {len(chunks)}",
246
+ f"**Aggregation:** {agg_label} ({agg_note}) | "
247
+ f"**Threshold:** {threshold:.2f}",
248
+ "",
249
+ "## Verdict",
250
+ "",
251
+ f"### {verdict(overall, threshold)}",
252
+ "",
253
+ f"`{bar(overall)}` **score = {overall:.3f}**"
254
+ f" ({'above' if overall >= threshold else 'below'} {threshold:.2f})",
255
+ "",
256
+ "| Metric | Value |",
257
+ "| --- | --- |",
258
+ f"| Score ({agg_label} of windows) | {overall:.3f} |",
259
+ f"| Mean of windows | {sum(chunk_probs) / len(chunk_probs):.3f} |",
260
+ f"| Max of windows | {max(chunk_probs):.3f} |",
261
+ f"| Sentences at or above threshold | {ai_sentences}/{len(sentence_probs)} |",
262
+ f"| Max sentence score | {max(sentence_probs):.3f} |",
263
+ f"| Min sentence score | {min(sentence_probs):.3f} |",
264
+ f"| Decision threshold | {threshold:.2f} |",
265
+ "",
266
+ "## Sentence-level breakdown",
267
+ "",
268
+ ]
269
+
270
+ for prob, sentence in zip(sentence_probs, sentences):
271
+ snippet = sentence if len(sentence) <= 160 else sentence[:157] + "..."
272
+ lines.append(f"- `{prob:.3f}` `{bar(prob, 12)}` {snippet}")
273
+
274
+ lines += [
275
+ "",
276
+ "## Most AI-like passages",
277
+ "",
278
+ ]
279
+ for prob, sentence in flagged:
280
+ snippet = sentence if len(sentence) <= 200 else sentence[:197] + "..."
281
+ lines.append(f"> **{prob:.3f}** - {snippet}")
282
+
283
+ lines += [
284
+ "",
285
+ "---",
286
+ "",
287
+ "_Scores are model probabilities, not proof. The model card warns against "
288
+ "using this as the sole basis for high-stakes decisions such as academic "
289
+ "or employment penalties, and notes degraded accuracy on text "
290
+ "distributions unlike its training data. The threshold is a display "
291
+ "choice: only 0.50 is documented upstream, and no false-positive rate is "
292
+ "claimed for any other value._",
293
+ ]
294
+
295
+ per_sentence = [
296
+ {"sentence": s, "p_ai": round(p, 4)} for p, s in zip(sentence_probs, sentences)
297
+ ]
298
+ summary = {
299
+ "backend": f"{QUANT_LABEL} on {DEVICE}",
300
+ "verdict": verdict(overall, threshold),
301
+ "score": round(overall, 4),
302
+ "score_max": round(max(chunk_probs), 4),
303
+ "score_mean": round(sum(chunk_probs) / len(chunk_probs), 4),
304
+ "aggregation": aggregation,
305
+ "threshold": round(threshold, 2),
306
+ "above_threshold": overall >= threshold,
307
+ "sentences_at_or_above_threshold": ai_sentences,
308
+ "windows": len(chunks),
309
+ "sentences": per_sentence,
310
+ }
311
+ return highlighted, "\n".join(lines), summary
312
+
313
+
314
+ with gr.Blocks(title="AI Text Detector") as demo:
315
+ gr.Markdown(
316
+ "# AI-Generated Text Detector\n"
317
+ "Paste text to estimate the probability it was written by a language "
318
+ f"model, using `{MODEL_ID}` ({QUANT_LABEL} on {DEVICE})."
319
+ )
320
+ with gr.Row():
321
+ with gr.Column(scale=2):
322
+ textbox = gr.Textbox(
323
+ label="Text",
324
+ lines=14,
325
+ placeholder="Paste the text you want to analyze...",
326
+ )
327
+ with gr.Row():
328
+ run = gr.Button("Analyze", variant="primary")
329
+ clear = gr.Button("Clear")
330
+ with gr.Accordion("Scoring options", open=False):
331
+ threshold = gr.Slider(
332
+ minimum=MIN_THRESHOLD,
333
+ maximum=MAX_THRESHOLD,
334
+ value=THRESHOLD,
335
+ step=0.01,
336
+ label="Decision threshold",
337
+ info="Upstream suggests 0.5. Raising it trades fewer false "
338
+ "positives for more misses.",
339
+ )
340
+ aggregation = gr.Radio(
341
+ choices=list(AGGREGATIONS),
342
+ value="mean",
343
+ label="Window aggregation",
344
+ info="mean averages every window; max reports the "
345
+ "highest-scoring window and is not diluted by human text "
346
+ "elsewhere. Identical when the text fits one window.",
347
+ )
348
+ gr.Examples(
349
+ examples=[
350
+ [
351
+ "I went to the store yesterday and bought three apples. "
352
+ "They were a bit bruised, but the price was right, and I "
353
+ "figured they'd be fine in a pie."
354
+ ],
355
+ [
356
+ "In today's rapidly evolving digital landscape, "
357
+ "organizations must leverage synergistic strategies to "
358
+ "unlock unprecedented value. By fostering a culture of "
359
+ "continuous innovation, stakeholders can navigate "
360
+ "complexity with confidence and drive meaningful outcomes."
361
+ ],
362
+ ],
363
+ inputs=textbox,
364
+ )
365
+ with gr.Column(scale=3):
366
+ highlighted = gr.HTML(
367
+ label="Sentence-level highlighting",
368
+ show_label=True,
369
+ container=True,
370
+ padding=True,
371
+ )
372
+ report = gr.Markdown(label="Report")
373
+ raw = gr.JSON(label="Machine-readable summary")
374
+
375
+ outputs = [highlighted, report, raw]
376
+ inputs = [textbox, threshold, aggregation]
377
+ run.click(analyze, inputs=inputs, outputs=outputs)
378
+ textbox.submit(analyze, inputs=inputs, outputs=outputs)
379
+ clear.click(
380
+ lambda: ("", "", None, None, THRESHOLD, "mean"),
381
+ outputs=[textbox, *outputs, threshold, aggregation],
382
+ )
383
+
384
+
385
+ if __name__ == "__main__":
386
+ print(
387
+ f"[aid] {MODEL_ID} loaded as {QUANT_LABEL} on {DEVICE} "
388
+ f"({MODEL_MB:,.0f} MB resident, {LOAD_SECONDS:.1f} s)",
389
+ flush=True,
390
+ )
391
+ score(["Warming up the model."])
392
+ # One inference at a time: on CPU the model saturates the vCPUs it is given.
393
+ demo.queue(default_concurrency_limit=1).launch()
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ # CPU-only torch: the default PyPI wheel is the CUDA build, several GB larger.
2
+ --extra-index-url https://download.pytorch.org/whl/cpu
3
+
4
+ gradio>=5.0
5
+ torch>=2.2
6
+ transformers>=4.48
7
+ sentencepiece
8
+ bitsandbytes>=0.50
9
+ accelerate>=1.1