Calibrating an AI Code Detector to Cut False Positives

The first batch I ran through an AI code detector came back with 31 of 140 submissions scored at 70 or above. The instructor read the list and said the sentence every instructor says: "That's not right, I know these students." She was partly right. About nine of those 31 had something worth a conversation. The other 22 were a mix of 18-line files, template-shaped boilerplate, and students who happen to write tidy code.

That gap between 31 and 9 is not a scandal and not a broken tool. It's a calibration problem, and it's fixable in an afternoon if you treat the detector as an instrument rather than an oracle.

What an AI code detector is actually measuring

Most detectors in this space, including the ones inside Codequiry's AI code detector, combine two families of signal. The first is statistical: token-level perplexity under a reference language model, plus some measure of variance across the file (burstiness, in the prose literature). Machine-generated token sequences tend to sit in high-probability regions of the model's distribution. The second is a supervised classifier over surface features: comment density, docstring style, error handling patterns, identifier vocabulary, import ordering, structural uniformity.

Both families were developed on natural language, and code is a hostile transfer target. Prose has enormous entropy at the token level. Code doesn't. A Python function that sums a list has maybe three plausible token-level paths, and a good student will pick the same one an LLM picks, because it's the obvious one. The Sadasivan et al. 2023 paper on reliable detection makes the general argument that as generators get closer to human distributions the statistical margin collapses. In code that margin is thin to begin with.

The detector isn't answering "did a model write this." It's answering "how far is this file from the token distribution I associate with human student code." Those are different questions, and the second one has a much wider error bar.

Score files, not submissions

A submission-level average is the wrong unit of analysis, and it's the first thing to fix in your workflow. Consider a student whose main.py is hand-written and whose utils.py came out of a chatbot after a frustrating evening. Averaged across eight files it reads as 34. Looked at per file, one file reads 91 and the rest read under 20. The signal lives at the file level, which is why a per-file AI analysis view matters more than a single headline number.

Here is the sort of code that pushes a file into the high band. It's a real pattern, not a strawman:

def calculate_average(numbers: list) -> float:
    """
    Calculate the average of a list of numbers.

    Args:
        numbers (list): A list of numeric values.

    Returns:
        float: The arithmetic mean of the list.
    """
    if not numbers:
        return 0.0
    try:
        total = sum(numbers)
        return total / len(numbers)
    except (TypeError, ZeroDivisionError) as e:
        print(f"Error calculating average: {e}")
        return 0.0

Count the tells. A Google-style docstring with Args and Returns on a six-line function. A try/except that catches ZeroDivisionError immediately after an explicit empty-list check has made that exception unreachable. An error path that prints instead of raising. A pointless total intermediate. Sentinels returned as 0.0 rather than raised. Each of these individually proves nothing. A careful TA writes this exact function when the rubric rewards defensive coding. The point is that the detector is reading style, and style is a proxy.

Codequiry per-file AI analysis showing AI versus human probability for each file with written indicators
Drilling into one submission: per-file AI and human probabilities, each with the stylistic indicators behind the score.

Measuring AI code detection false positives on your own corpus

You cannot get a meaningful false positive rate from someone else's benchmark. You can get one from your own archive, and the archive is right there.

Pull two or three prior semesters of submissions from before November 2022. Any file written then was not written by ChatGPT, which makes every high score in that corpus a false positive by construction (run peer and web similarity first to exclude the students who copied from each other, or you'll contaminate the number). Then plot score against file length. In the corpus I've worked with, the results looked roughly like this:

File lengthFlag rate at score 70Flag rate at score 85
under 30 LOC14%3%
30 to 100 LOC6%1.2%
over 100 LOC2.8%0.4%

Short files are the whole problem. A 20-line submission has too few tokens for any distributional estimate to stabilize, and boilerplate (input parsing, a class with getters, a standard if __name__ == "__main__" block) is the most machine-like text a human will ever produce on purpose. Set a minimum file length before you act on a score at all. Forty lines is a defensible floor and 60 is safer.

One detail worth planning for: the column names in the CSV you download from the dashboard are not identical to the field names the API returns, so if your first pipeline reads the export directly and later switches to the API, expect a week of quiet breakage. Ask me how I know.

Normalize against the assignment, not against a fixed number

A fixed threshold ignores the fact that some specs pull every submission toward the same shape. An assignment that asks for a REST handler with validation produces uniformly conventional code, and the whole cohort scores 55. An assignment that asks for a novel algorithm produces scattered, idiosyncratic code, and the cohort scores 25. You want to know which students are outliers inside their own cohort.

import statistics as st

usable = [r for r in rows if r["loc"] >= 40]
mu = st.mean(r["max_file_ai"] for r in usable)
sd = st.pstdev([r["max_file_ai"] for r in usable]) or 1.0

for r in usable:
    z = (r["max_file_ai"] - mu) / sd
    if z > 2.0 and r["peer_similarity"] < 40:
        print(r["submission_id"], round(r["max_file_ai"], 1), round(z, 2))

On a class of 140 that filter typically returns a dozen names rather than 31, and the precision of the list is far better. It also degrades gracefully: if an assignment has no real outliers, the z-filter returns nothing, which a fixed threshold never does.

Codequiry AI detection table listing submissions with AI score ranges and review statuses
Per-submission AI scores with ranges and review statuses, so graders start conversations instead of guessing.

Stack the AI score with peer and web similarity

The single most effective way to reduce false positives is to stop looking at the AI score in isolation. Three signals disagree in informative ways.

A high AI score with low peer similarity and no web matches is the ambiguous case: the student either used a model or writes unusually conventional code. A high AI score with a 95% peer match is not an AI question at all, it's a plagiarism question, and someone else's submission is your comparison document. A modest AI score with a 100% match to a GitHub repository is a provenance question, answered by a timestamp. Each pair of signals points at a different conversation.

This is the practical argument for running one check that returns all three numbers rather than three tools that each return one. MOSS gives you peer similarity and nothing else, and its results are an HTML file you parse by hand. JPlag and Dolos are similarity engines with good AST-level comparison and no AI signal at all. Turnitin's AI writing indicator is tuned for prose and does not do token or AST comparison over source files. Codequiry runs peer similarity, a web and GitHub search, and AI-detection over the same submission set, which means the three scores are attached to the same file and the same scan.

Codequiry insights score breakdown separating peer similarity, web similarity and AI generation, with match sources
The score breakdown: peer similarity, web similarity and AI probability reported separately, with where the matches came from.

What to do with a flagged file

Interview the student. Not an accusation, a conversation: ask them to walk through one function, then ask for a small change to it live. A student who wrote the code can extend it. A student who pasted it usually cannot, and the failure is specific and gentle rather than theatrical.

Check the artifacts first, because they're cheaper than the meeting. Git history with realistic commit granularity is strong evidence of authorship. So is an editor's local history or a cloud document's version timeline. A single commit named "final" containing 600 lines is weak evidence but not proof; plenty of students work offline and commit once.

Write the threshold policy down before the semester starts, including what happens at each level of evidence. A detector whose output feeds an honor code hearing without a documented policy is a liability, particularly when the score is the only exhibit. Weber-Wulff et al. 2023 tested fourteen AI-text detectors and found none that met a reasonable bar for academic consequences on their own, and the code-specific versions deserve the same skepticism.

Where AI code detection breaks down

Four places, in rough order of how often they bite.

Short files. Already covered, and it's the biggest one.

Human-edited machine output. An LLM drafts the parser, the student rewrites the error handling and renames everything, and the AI score drops from 88 to 30. The file is genuinely co-authored and no single number describes it. If your policy treats AI assistance as a spectrum, the score should be one input to a judgment about how much of the work is the student's, and you'll need to ask.

Inline completion. Copilot and its equivalents suggest a line at a time. A student accepting suggestions all afternoon produces a file that is partly generated and partly not, with no clean boundary. The label "AI-generated" is the wrong question for that file. "Can you explain and modify this" is the right one.

Convention-heavy writers. Liang et al. 2023 showed prose detectors flag non-native English writers at much higher rates because those writers use more predictable phrasing. The code analogue is a student who learned from the same three tutorial series as everyone else and writes in that idiom throughout. Their score is elevated for reasons that have nothing to do with a model. This is where the cohort z-score helps: if everyone in the class learned from the same tutorials, the class average rises and the writer stops looking like an outlier.

Frequently Asked Questions

Can professors tell if an assignment was written by ChatGPT just by reading it?

Not reliably. Experienced instructors catch the obvious cases (over-documentation, unreachable error handling, the same helper structure across unrelated students) and miss the edited ones. Reading is a useful filter, not a measurement.

What false positive rate is acceptable for an AI code detector?

It depends on what happens next. If a flag triggers a fifteen-minute conversation, 5% is fine. If it triggers an honor code referral, you want under 1% and corroborating evidence in every case. Multiply the rate by your cohort size before you decide.

Does renaming variables or reformatting defeat AI code detection?

Reformatting and renaming defeat surface-level text similarity completely, and they do dent perplexity-based scoring. Classifier-based detection over structure and style is more resilient, which is why token and AST comparison matters alongside text matching. None of it is unbeatable; the goal is a signal good enough to justify asking.

Should I scan every assignment or just the suspicious ones?

Every assignment, and early. A scan in week two costs you one conversation. The same behavior discovered in week eleven costs a hearing.

If you want the per-file scoring, the cohort view, and the peer and web numbers on the same report, that's what a single code plagiarism checker run over the assignment gives you, and it takes about as long as uploading a zip file.