MELD β AI-generated text detector
MELD reads an English document and reports how machine-written it looks. 395M parameters, runs on CPU or a single GPU.
This repository contains the v5 release (2026-07-31). Earlier revisions of this repository held different models β scores are not comparable across them.
Files
Everything needed to run the model is here. Nothing is downloaded at load time.
| file | what it is |
|---|---|
model.safetensors |
the weights (fp32, 1.6 GB) |
config.json |
encoder architecture |
meld_config.json |
scoring head, thresholds |
tokenizer.json, tokenizer_config.json, special_tokens_map.json |
tokenizer |
Install
pip install torch transformers safetensors
Use
Use the code below β the scoring head is custom, so pipeline(),
AutoModelForSequenceClassification and AutoModel do not work here. They
load without an error, discard every weight in this repository, and return
numbers from a randomly initialised model.
Download this repository into a folder, point MODEL_DIR at it, and run:
import json
import torch
import torch.nn as nn
from safetensors.torch import load_file
from transformers import AutoConfig, AutoModel, AutoTokenizer
MODEL_DIR = "meld" # the folder you downloaded this repo into
class Meld(nn.Module):
def __init__(self, model_dir):
super().__init__()
self.cfg = json.load(open(f"{model_dir}/meld_config.json"))
r, H = self.cfg["style_rank"], self.cfg["backbone_hidden_size"]
self.backbone = AutoModel.from_config(
AutoConfig.from_pretrained(model_dir), attn_implementation="sdpa"
)
self.style_proj = nn.Linear(H, r, bias=False)
self.style_ln = nn.LayerNorm(r)
self.human_anchors = nn.Parameter(torch.zeros(self.cfg["n_human_anchors"], r))
self.family_protos = nn.Parameter(torch.zeros(self.cfg["n_families"], r))
self.family_bias = nn.Parameter(torch.zeros(self.cfg["n_families"]))
self.log_tau = nn.Parameter(torch.zeros(()))
self.op_protos = nn.Parameter(torch.zeros(self.cfg["n_ops"], r))
self.op_bias = nn.Parameter(torch.zeros(self.cfg["n_ops"]))
self.load_state_dict(load_file(f"{model_dir}/model.safetensors"), strict=True)
self.eval()
@torch.no_grad()
def score(self, texts, tokenizer, device="cpu"):
"""Returns P(AI) in [0, 1], one per text."""
enc = tokenizer(texts, return_tensors="pt", padding=True, truncation=True,
max_length=self.cfg["max_length"],
return_special_tokens_mask=True).to(device)
valid = enc["attention_mask"].bool() & ~enc["special_tokens_mask"].bool()
h = self.backbone(input_ids=enc["input_ids"],
attention_mask=enc["attention_mask"]).last_hidden_state.float()
u = self.style_ln(self.style_proj(h)) # style coordinates
tau = self.log_tau.clamp(-4.0, 4.0).exp()
def sqdist(u, p): # (B, L, P)
return ((u * u).sum(-1, keepdim=True) - 2.0 * u @ p.t()
+ (p * p).sum(-1).view(1, 1, -1))
human = torch.logsumexp(-tau * sqdist(u, self.human_anchors), -1, keepdim=True)
family = -tau * sqdist(u, self.family_protos) + self.family_bias.view(1, 1, -1)
per_token = (self.cfg["tau_agg"] * torch.logsumexp(
(family - human).clamp(-30.0, 30.0) / self.cfg["tau_agg"], dim=-1))
# document score = mean of the most machine-like rho fraction of tokens
x = per_token.masked_fill(~valid, torch.finfo(per_token.dtype).min)
x, _ = x.sort(dim=1, descending=True)
k = (valid.sum(1).clamp(min=1) * self.cfg["rho"]).ceil().clamp(min=1).long()
keep = torch.arange(x.shape[1], device=x.device).unsqueeze(0) < k.unsqueeze(1)
s = torch.where(keep, x, torch.zeros_like(x)).sum(1) / k.float()
return torch.sigmoid(s).tolist(), s.tolist()
device = "cuda" if torch.cuda.is_available() else "cpu"
model = Meld(MODEL_DIR).to(device)
tokenizer = AutoTokenizer.from_pretrained(MODEL_DIR)
texts = ["Paste the document you want to check here."]
probs, scores = model.score(texts, tokenizer, device)
# Flag at the threshold shipped in meld_config.json: it is the score below
# which 99% of human texts fell on our validation set (a 1% false-positive rate).
threshold = model.cfg["score_offsets"]["overall"]["fpr_0.01"]
for text, p, s in zip(texts, probs, scores):
print(f"P(AI) = {p:.3f} flagged = {s > threshold}")
Three things worth knowing before you trust a number:
- Give it at least 100 words. On shorter text the score runs high whatever wrote it β below 50 words it flags most genuine human writing.
- The model reads the first 2,048 tokens. For a long document, split it into sections and score each one.
- Keep the original line breaks. Re-flowing paragraphs shifts the score on its own, so feed the text as it was written and pass a few dozen documents per call rather than thousands at once.
Reading the output
Pick a threshold β don't compare P(AI) to a round number like 0.5 or 0.1.
Human writing does not sit near zero; on human academic prose the average
document scores around 0.2, so a fixed absolute cut will flag almost everything
or nothing depending on the number you pick.
- Use the shipped threshold.
meld_config.jsonstoresscore_offsets. Compare the raw scores(notP(AI)) toscore_offsets["overall"]["fpr_0.01"]for a 1% false-positive rate, orfpr_0.05/fpr_0.1for looser settings.score_offsets["strata"]has separate values for academic, web, wiki, reviews, creative, and QA text. - Or use your own reference set, which is better if you have one: score a batch of documents you know are human and come from the same place as the ones you are testing, and take a high percentile of those scores as your cut.
Good to know
- The score says how a text reads, not how it was written, so it works best as a rate across many documents rather than as a verdict on one.
- Year-dense text carries a little spurious signal: rewriting every four-digit year in a document shifts the mean score by up to 0.03.
License
MIT, following the Ettin-400m backbone it is built on.
- Downloads last month
- 360
Model tree for anon-review-meld-2026/meld
Base model
jhu-clsp/ettin-encoder-400m