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.json stores score_offsets. Compare the raw score s (not P(AI)) to score_offsets["overall"]["fpr_0.01"] for a 1% false-positive rate, or fpr_0.05 / fpr_0.1 for 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
Safetensors
Model size
0.4B params
Tensor type
F32
Β·
Inference Providers NEW
This model isn't deployed by any Inference Provider. πŸ™‹ Ask for provider support

Model tree for anon-review-meld-2026/meld

Finetuned
(8)
this model

Space using anon-review-meld-2026/meld 1