| """ |
| LLM-Generated Text Detector |
| University Project - AI Text Detection System |
| Uses ensemble of: RoBERTa classifier + GPT-2 Perplexity + Burstiness scoring |
| Hosted on Hugging Face Spaces β CPU compatible, no GPU required, no API keys |
| """ |
|
|
| import gradio as gr |
| import torch |
| import math |
| import re |
| import json |
| import numpy as np |
| from transformers import ( |
| AutoTokenizer, |
| AutoModelForSequenceClassification, |
| GPT2LMHeadModel, |
| GPT2TokenizerFast, |
| ) |
|
|
| |
| try: |
| from langdetect import detect as _langdetect, LangDetectException |
| LANGDETECT_AVAILABLE = True |
| except ImportError: |
| LANGDETECT_AVAILABLE = False |
|
|
| |
| MAX_ROBERTA_TOKENS = 512 |
| CHUNK_SIZE = 480 |
| CHUNK_OVERLAP = 50 |
|
|
| |
| HOW_IT_WORKS = """### How this system works |
| |
| This detector uses an **ensemble of three independent methods**: |
| |
| | Method | What it measures | Weight | |
| |---|---|---| |
| | RoBERTa classifier | Learned patterns from millions of AI/human text pairs | 60% | |
| | GPT-2 perplexity | How predictable the text is to a language model | 25% | |
| | Burstiness analysis | Variation in sentence length across the text | 15% | |
| |
| The three scores are combined using a weighted average to produce the final AI probability. |
| RoBERTa is given the highest weight because it was specifically trained for this task |
| and consistently outperforms statistical methods in peer-reviewed benchmarks. |
| |
| **Confidence** is calculated from the agreement between the three methods. |
| High confidence means all three signals agree. Low confidence means they diverge β |
| the verdict should be treated with more caution in that case. |
| |
| **Long text handling:** texts longer than ~380 words (~480 tokens) are automatically |
| split into overlapping chunks, each chunk is scored independently, and the scores are |
| averaged. This ensures the full document is analysed rather than just the first 380 words. |
| |
| **Language:** all three models are optimised for English. A warning is shown if |
| non-English input is detected. |
| |
| **References:** |
| - Guo et al. (2023). *How Close is ChatGPT to Human Experts?* (HC3 dataset, RoBERTa) |
| - Mitchell et al. (2023). *DetectGPT: Zero-Shot Machine-Generated Text Detection* |
| - Hans et al. (2024). *Spotting LLMs With Binoculars* (ICML 2024) |
| - Bao et al. (2024). *Fast-DetectGPT* (ICLR 2024) |
| """ |
|
|
| |
|
|
| ROBERTA_MODEL = "Hello-SimpleAI/chatgpt-detector-roberta" |
| GPT2_MODEL = "gpt2" |
|
|
| print("Loading models⦠(cached automatically by Hugging Face after first run)") |
|
|
| roberta_tokenizer = AutoTokenizer.from_pretrained(ROBERTA_MODEL) |
| roberta_model = AutoModelForSequenceClassification.from_pretrained(ROBERTA_MODEL) |
| roberta_model.eval() |
|
|
| gpt2_tokenizer = GPT2TokenizerFast.from_pretrained(GPT2_MODEL) |
| gpt2_tokenizer.pad_token = gpt2_tokenizer.eos_token |
| gpt2_lm = GPT2LMHeadModel.from_pretrained(GPT2_MODEL) |
| gpt2_lm.eval() |
|
|
| |
| |
| |
| |
| _label_lookup = {v.lower(): k for k, v in roberta_model.config.id2label.items()} |
| AI_LABEL_IDX = ( |
| _label_lookup.get("chatgpt") |
| or _label_lookup.get("fake") |
| or _label_lookup.get("ai") |
| or _label_lookup.get("generated") |
| or 1 |
| ) |
| print(f"Models loaded β | RoBERTa AI label β [{AI_LABEL_IDX}] " |
| f"'{roberta_model.config.id2label[AI_LABEL_IDX]}'") |
|
|
| |
|
|
| def _roberta_score_single(text: str) -> float: |
| """Score a single text chunk (must be within the token limit).""" |
| inputs = roberta_tokenizer( |
| text, return_tensors="pt", truncation=True, max_length=MAX_ROBERTA_TOKENS |
| ) |
| with torch.no_grad(): |
| logits = roberta_model(**inputs).logits |
| probs = torch.softmax(logits, dim=-1) |
| return float(probs[0][AI_LABEL_IDX].item()) |
|
|
|
|
| def roberta_score(text: str) -> tuple: |
| """ |
| RoBERTa fine-tuned classifier β returns (P(AI-generated), was_chunked). |
| |
| Texts longer than CHUNK_SIZE tokens are automatically split into overlapping |
| windows. Each window is scored independently and the mean is returned. |
| This prevents the silent truncation that occurred before. |
| """ |
| token_ids = roberta_tokenizer.encode(text, add_special_tokens=False) |
|
|
| if len(token_ids) <= CHUNK_SIZE: |
| return _roberta_score_single(text), False |
|
|
| |
| chunk_scores = [] |
| start = 0 |
| while start < len(token_ids): |
| end = min(start + CHUNK_SIZE, len(token_ids)) |
| chunk = roberta_tokenizer.decode(token_ids[start:end], skip_special_tokens=True) |
| chunk_scores.append(_roberta_score_single(chunk)) |
| if end == len(token_ids): |
| break |
| start += CHUNK_SIZE - CHUNK_OVERLAP |
|
|
| return float(np.mean(chunk_scores)), True |
|
|
|
|
| def gpt2_perplexity(text: str) -> float: |
| """ |
| GPT-2 perplexity β LLM text tends to be LOW perplexity |
| (the model finds it very predictable). |
| Returns perplexity value; lower β more likely AI-generated. |
| """ |
| encodings = gpt2_tokenizer(text, return_tensors="pt", truncation=True, max_length=1024) |
| input_ids = encodings.input_ids |
| if input_ids.shape[1] < 5: |
| return 9999.0 |
| with torch.no_grad(): |
| outputs = gpt2_lm(input_ids, labels=input_ids) |
| loss = outputs.loss.item() |
| return math.exp(loss) |
|
|
|
|
| def burstiness_score(text: str) -> float: |
| """ |
| Sentence-length burstiness β humans vary sentence length more. |
| AI text is uniformly structured β low burstiness. |
| Returns 0-1 where LOWER = more AI-like. |
| """ |
| sentences = re.split(r'[.!?]+', text) |
| lengths = [len(s.split()) for s in sentences if s.strip()] |
| if len(lengths) < 3: |
| return 0.5 |
| mean_len = np.mean(lengths) |
| std_len = np.std(lengths) |
| if mean_len == 0: |
| return 0.5 |
| cv = std_len / mean_len |
| |
| burstiness = min(cv / 0.8, 1.0) |
| return float(burstiness) |
|
|
|
|
| def detect_language_safe(text: str): |
| """Returns ISO 639-1 language code or None on any failure.""" |
| if not LANGDETECT_AVAILABLE: |
| return None |
| try: |
| return _langdetect(text) |
| except Exception: |
| return None |
|
|
|
|
| def compute_confidence(scores: list) -> tuple: |
| """ |
| Confidence = 1 β (normalised std of the three component scores). |
| std=0 β perfect agreement β high confidence. |
| std=0.5 β maximum spread β low confidence. |
| Returns (confidence_float, label_string). |
| """ |
| std = float(np.std(scores)) |
| confidence = max(0.0, min(1.0, 1.0 - (std / 0.5))) |
| if confidence >= 0.75: |
| label = "High confidence" |
| elif confidence >= 0.50: |
| label = "Moderate confidence" |
| else: |
| label = "Low confidence β signals disagree" |
| return round(confidence, 3), label |
|
|
|
|
| def ensemble_predict(text: str): |
| """ |
| Combine all three signals into a final verdict. |
| Returns: (ai_probability, verdict, confidence, details_dict) |
| """ |
| text = text.strip() |
| word_count = len(text.split()) |
| if word_count < 20: |
| return None, "β οΈ Too short", None, { |
| "error": "Please enter at least 20 words for reliable detection." |
| } |
|
|
| |
| lang = detect_language_safe(text) |
| lang_warning = None |
| if lang and lang != "en": |
| lang_warning = ( |
| f"β οΈ Detected language: **{lang}** β " |
| "all models are optimised for English; results may be unreliable." |
| ) |
|
|
| |
| rb_score, was_chunked = roberta_score(text) |
| perplexity = gpt2_perplexity(text) |
| burst = burstiness_score(text) |
|
|
| |
| |
| |
| _log_ppl = math.log(max(perplexity, 1.0)) |
| _log_min = math.log(20) |
| _log_max = math.log(300) |
| ppl_ai_prob = max(0.0, min(1.0, 1.0 - (_log_ppl - _log_min) / (_log_max - _log_min))) |
|
|
| |
| burst_ai_prob = max(0.0, min(1.0, 1.0 - burst)) |
|
|
| |
| weights = [0.60, 0.25, 0.15] |
| scores = [rb_score, ppl_ai_prob, burst_ai_prob] |
| final_prob = round(sum(w * s for w, s in zip(weights, scores)), 4) |
|
|
| |
| confidence, conf_label = compute_confidence(scores) |
|
|
| |
| if final_prob >= 0.75: |
| verdict = "π€ Almost certainly AI-generated" |
| elif final_prob >= 0.55: |
| verdict = "β οΈ Likely AI-generated" |
| elif final_prob >= 0.40: |
| verdict = "π Uncertain β mixed signals" |
| elif final_prob >= 0.25: |
| verdict = "π Likely human-written" |
| else: |
| verdict = "β
Almost certainly human-written" |
|
|
| details = { |
| "word_count": word_count, |
| "was_chunked": was_chunked, |
| "language": lang or "unknown", |
| "lang_warning": lang_warning, |
| "roberta_ai_prob": round(rb_score, 4), |
| "perplexity": round(perplexity, 2), |
| "perplexity_ai_prob": round(ppl_ai_prob, 4), |
| "burstiness": round(burst, 4), |
| "burstiness_ai_prob": round(burst_ai_prob, 4), |
| "ensemble_ai_prob": final_prob, |
| "confidence": confidence, |
| "confidence_label": conf_label, |
| } |
| return final_prob, verdict, confidence, details |
|
|
|
|
| |
|
|
| def format_bar(prob: float) -> str: |
| filled = int(prob * 20) |
| empty = 20 - filled |
| return "β" * filled + "β" * empty |
|
|
|
|
| def detect(text): |
| if not text or not text.strip(): |
| return ( |
| "Please paste some text above.", |
| "", |
| "", |
| "", |
| "", |
| "", |
| ) |
|
|
| final_prob, verdict, d = ensemble_predict(text) |
|
|
| if "error" in d: |
| return d["error"], "", "", "", "", "" |
|
|
| human_prob = round(1.0 - final_prob, 4) |
|
|
| |
| pct = int(final_prob * 100) |
| bar = format_bar(final_prob) |
|
|
| summary = f"""## {verdict} |
| |
| **AI Probability: {pct}%** `{bar}` |
| **Human Probability: {int(human_prob*100)}%** `{format_bar(human_prob)}` |
| """ |
|
|
| |
| rb_pct = int(d["roberta_ai_prob"] * 100) |
| rb_bar = format_bar(d["roberta_ai_prob"]) |
| rb_info = f"""### π§ RoBERTa Classifier *(weight: 60%)* |
| Fine-tuned on millions of human vs. AI text pairs. Most reliable signal. |
| |
| **AI score: {rb_pct}%** `{rb_bar}` |
| |
| > RoBERTa is a transformer model trained specifically to distinguish human and |
| > AI-generated text. It was fine-tuned on the HC3 dataset covering ChatGPT, |
| > GPT-3, and GPT-4 outputs across multiple domains. |
| """ |
|
|
| |
| ppl = d["perplexity"] |
| ppl_ai = int(d["perplexity_ai_prob"] * 100) |
| if ppl < 50: |
| ppl_label = "Very low β strong AI signal" |
| elif ppl < 100: |
| ppl_label = "Low β possible AI signal" |
| elif ppl < 200: |
| ppl_label = "Medium β ambiguous" |
| else: |
| ppl_label = "High β more human-like" |
|
|
| ppl_info = f"""### π GPT-2 Perplexity *(weight: 25%)* |
| Measures how "surprising" the text is to a language model. |
| AI-generated text is typically very predictable (low perplexity). |
| |
| **Perplexity: {ppl:.1f}** β {ppl_label} |
| **AI signal: {ppl_ai}%** `{format_bar(d["perplexity_ai_prob"])}` |
| |
| > Typical ranges: AI text = 20β80 | Human text = 100β300+ |
| > *(Note: technical/formal writing can have naturally low perplexity)* |
| """ |
|
|
| |
| burst = d["burstiness"] |
| burst_ai = int(d["burstiness_ai_prob"] * 100) |
| if burst < 0.3: |
| burst_label = "Very uniform β strong AI signal" |
| elif burst < 0.5: |
| burst_label = "Somewhat uniform β possible AI signal" |
| else: |
| burst_label = "Variable β more human-like" |
|
|
| burst_info = f"""### π Sentence Burstiness *(weight: 15%)* |
| Measures how much sentence lengths vary throughout the text. |
| Humans write with natural rhythm β short bursts, long explanations. |
| AI tends to produce uniform sentence lengths. |
| |
| **Burstiness coefficient: {burst:.3f}** β {burst_label} |
| **AI signal: {burst_ai}%** `{format_bar(d["burstiness_ai_prob"])}` |
| |
| > Low coefficient = uniform sentence lengths (AI-like) |
| > High coefficient = varied sentence lengths (human-like) |
| """ |
|
|
| how_it_works = """### How This System Works |
| |
| This detector uses an **ensemble of three independent methods**: |
| |
| | Method | What it measures | Weight | |
| |---|---|---| |
| | RoBERTa classifier | Learned patterns from millions of AI/human text pairs | 60% | |
| | GPT-2 perplexity | How predictable the text is to a language model | 25% | |
| | Burstiness analysis | Variation in sentence length across the text | 15% | |
| |
| The three scores are combined using a weighted average to produce the final AI probability. |
| A higher weight is given to RoBERTa because it was specifically trained for this task |
| and consistently outperforms statistical methods in peer-reviewed benchmarks. |
| |
| **References:** |
| - Guo et al. (2023). *How Close is ChatGPT to Human Experts?* (HC3 dataset, RoBERTa) |
| - Mitchell et al. (2023). *DetectGPT: Zero-Shot Machine-Generated Text Detection* |
| - Hans et al. (2024). *Spotting LLMs With Binoculars* (ICML 2024) |
| - Bao et al. (2024). *Fast-DetectGPT* (ICLR 2024) |
| """ |
|
|
| def live_counter(text: str) -> str: |
| """Live word + token count shown below the text box as the user types.""" |
| if not text or not text.strip(): |
| return "*0 words Β· 0 tokens*" |
| words = len(text.strip().split()) |
| tokens = len(roberta_tokenizer.encode(text, add_special_tokens=False)) |
| status = "β" if words >= 20 else f"β need at least 20" |
| chunk_note = f" Β· **will be chunked** ({math.ceil(tokens / CHUNK_SIZE)} chunks)" \ |
| if tokens > CHUNK_SIZE else "" |
| return f"*{words} words Β· {tokens} tokens {status}{chunk_note}*" |
|
|
|
|
| def detect(text): |
| """Main detection handler β called when the user clicks Analyse.""" |
| if not text or not text.strip(): |
| return "*Results will appear hereβ¦*", "", "", "", "" |
|
|
| final_prob, verdict, confidence, d = ensemble_predict(text) |
|
|
| if "error" in d: |
| return d["error"], "", "", "", "" |
|
|
| human_prob = round(1.0 - final_prob, 4) |
| pct = int(final_prob * 100) |
| conf_pct = int(d["confidence"] * 100) |
|
|
| |
| lang_banner = f"\n> {d['lang_warning']}\n" if d.get("lang_warning") else "" |
| chunk_notice = ( |
| f"\n> π Long text β analysis averaged across " |
| f"{math.ceil(len(roberta_tokenizer.encode(text, add_special_tokens=False)) / CHUNK_SIZE)} chunks.\n" |
| ) if d["was_chunked"] else "" |
|
|
| summary = f"""## {verdict} |
| {lang_banner}{chunk_notice} |
| **AI probability:** {pct}% `{format_bar(final_prob)}` |
| |
| **Human probability:** {int(human_prob*100)}% `{format_bar(human_prob)}` |
| |
| **{d['confidence_label']}** `{format_bar(d['confidence'])}` {conf_pct}% |
| """ |
|
|
| |
| rb_pct = int(d["roberta_ai_prob"] * 100) |
| chunk_tag = "\nπ *Text exceeded 512 tokens β scored across multiple overlapping chunks.*\n" \ |
| if d["was_chunked"] else "" |
| rb_info = f"""### π§ RoBERTa Classifier *(weight: 60%)* |
| Fine-tuned on millions of human vs. AI text pairs. Most reliable signal. |
| {chunk_tag} |
| **AI score: {rb_pct}%** `{format_bar(d['roberta_ai_prob'])}` |
| |
| > Fine-tuned on the HC3 dataset (Guo et al., 2023) β 37,175 QA pairs comparing |
| > ChatGPT and human expert answers across multiple domains. |
| > Architecture: RoBERTa-base with a binary classification head. |
| """ |
|
|
| |
| ppl = d["perplexity"] |
| ppl_ai = int(d["perplexity_ai_prob"] * 100) |
| if ppl < 50: |
| ppl_label = "Very low β strong AI signal" |
| elif ppl < 100: |
| ppl_label = "Low β possible AI signal" |
| elif ppl < 200: |
| ppl_label = "Medium β ambiguous" |
| else: |
| ppl_label = "High β more human-like" |
|
|
| ppl_info = f"""### π GPT-2 Perplexity *(weight: 25%)* |
| Measures how "surprising" the text is to a language model. |
| AI-generated text is typically very predictable (low perplexity). |
| |
| **Perplexity: {ppl:.1f}** β {ppl_label} |
| **AI signal: {ppl_ai}%** `{format_bar(d['perplexity_ai_prob'])}` |
| |
| > Typical ranges: AI text = 20β80 | Human text = 100β300+ |
| > Scoring uses log-scale normalisation (perplexity is log-normally distributed). |
| > *(Note: technical/formal human writing can also have low perplexity)* |
| """ |
|
|
| |
| burst = d["burstiness"] |
| burst_ai = int(d["burstiness_ai_prob"] * 100) |
| if burst < 0.3: |
| burst_label = "Very uniform β strong AI signal" |
| elif burst < 0.5: |
| burst_label = "Somewhat uniform β possible AI signal" |
| else: |
| burst_label = "Variable β more human-like" |
|
|
| burst_info = f"""### π Sentence Burstiness *(weight: 15%)* |
| Measures how much sentence lengths vary throughout the text. |
| Humans write with natural rhythm β short bursts mixed with long explanations. |
| AI tends to produce sentences of similar length. |
| |
| **Burstiness coefficient: {burst:.3f}** β {burst_label} |
| **AI signal: {burst_ai}%** `{format_bar(d['burstiness_ai_prob'])}` |
| |
| > Low coefficient = uniform sentence lengths (AI-like) |
| > High coefficient = varied sentence lengths (human-like) |
| """ |
|
|
| |
| clean_details = {k: v for k, v in d.items() if k != "lang_warning"} |
| raw_json = json.dumps(clean_details, indent=2) |
|
|
| return summary, rb_info, ppl_info, burst_info, raw_json |
|
|
|
|
| |
|
|
| CSS = """ |
| .gradio-container { max-width: 980px !important; margin: auto; } |
| footer { display: none !important; } |
| """ |
|
|
| |
| EXAMPLES = [ |
| |
| ["Large language models (LLMs) have demonstrated remarkable capabilities across a wide range of natural language processing tasks. These models, trained on vast corpora of text data, leverage transformer architectures to generate coherent and contextually appropriate responses. The implications for various industries are significant and multifaceted. In healthcare, these models can assist with medical documentation and patient communication. In education, they enable personalised tutoring and instant feedback mechanisms."], |
| |
| ["So I finally tried making sourdough bread today and honestly?? It went terribly lol. The starter looked fine I thought. But then the dough was basically soup and I didn't know what I was doing so I just kept adding flour. Ended up with this weird dense brick thing that my roommate ate anyway because she was hungry. Will try again next weekend I guess. Maybe watch some YouTube videos first this time omg"], |
| |
| ["The mitochondria is the powerhouse of the cell. This organelle is responsible for producing ATP through a process called cellular respiration. It has a double membrane structure with the inner membrane folded into cristae. The matrix inside contains enzymes needed for the Krebs cycle."], |
| |
| ["The city council voted 5-2 last Tuesday to approve a rezoning proposal allowing three new apartment complexes near Riverside Park. Opponents argued the development would increase traffic and strain local schools. Supporters said it would help ease the housing shortage. Mayor Johnson, who abstained from the vote, said she would review the decision before signing off on the permits."], |
| ] |
|
|
| with gr.Blocks(css=CSS, title="LLM Text Detector") as demo: |
| gr.Markdown(""" |
| # π LLM-Generated Text Detector |
| |
| ## Build BY: |
| Muhammad Zain-(F2023266257) |
| Muhammad Umar-(F20232661022) |
| # Under Supervision of |
| MAM NITASHA AROOJ |
| ## Contact: |
| nitasha.janjua@umt.edu.pk |
| |
| # Capabilities: |
| ## Ensemble detection Β· Hugging Face Spaces (For Deployment and Resources) Β· Independent Setup |
| Detects AI-generated text from GPT-4, GPT-4o, Claude, Gemini, Llama, DeepSeek, and more. |
| Three independent methods + confidence scoring + automatic long-text chunking |
| --- |
| """) |
|
|
| with gr.Row(): |
| with gr.Column(scale=3): |
| text_input = gr.Textbox( |
| label="Paste text to analyse", |
| placeholder="Paste any text here (minimum 20 words)β¦", |
| lines=10, |
| ) |
| |
| counter_md = gr.Markdown("*0 words Β· 0 tokens*") |
| with gr.Row(): |
| submit_btn = gr.Button("π Analyse Text", variant="primary", scale=3) |
| clear_btn = gr.Button("Clear", scale=1) |
|
|
| gr.Examples( |
| examples=EXAMPLES, |
| inputs=text_input, |
| label="Example texts to try", |
| ) |
|
|
| with gr.Column(scale=2): |
| result_summary = gr.Markdown(value="*Results will appear hereβ¦*") |
|
|
| gr.Markdown("---") |
| gr.Markdown("## Detailed breakdown") |
|
|
| with gr.Tabs(): |
| with gr.Tab("π§ RoBERTa Classifier"): |
| result_roberta = gr.Markdown() |
| with gr.Tab("π Perplexity Analysis"): |
| result_ppl = gr.Markdown() |
| with gr.Tab("π Burstiness Analysis"): |
| result_burst = gr.Markdown() |
| with gr.Tab("βΉοΈ How It Works"): |
| |
| gr.Markdown(HOW_IT_WORKS) |
| with gr.Tab("π’ Raw Scores (JSON)"): |
| result_raw = gr.Code(language="json") |
|
|
| |
| text_input.change( |
| fn=live_counter, |
| inputs=text_input, |
| outputs=counter_md, |
| ) |
|
|
| submit_btn.click( |
| fn=detect, |
| inputs=text_input, |
| outputs=[result_summary, result_roberta, result_ppl, result_burst, result_raw], |
| ) |
|
|
| |
| clear_btn.click( |
| fn=lambda: ("", "*0 words Β· 0 tokens*", "*Results will appear hereβ¦*", "", "", "", ""), |
| inputs=None, |
| outputs=[text_input, counter_md, result_summary, result_roberta, result_ppl, result_burst, result_raw], |
| ) |
|
|
| gr.Markdown(""" |
| --- |
| **Models:** `Hello-SimpleAI/chatgpt-detector-roberta` Β· `gpt2` |
| **References:** HC3 (Guo et al. 2023) Β· DetectGPT (Mitchell et al. 2023) Β· Binoculars (Hans et al. 2024) Β· Fast-DetectGPT (Bao et al. 2024) |
| """) |
|
|
| demo.queue() |
| if __name__ == "__main__": |
| demo.launch() |