Skip to content
October 3, 2026•9 min read•By Dzaki Amri Zaidaan

Benchmarking Frontier LLMs on Indian Vernacular: A 123-Participant Kaggle Study

A reproducible methodology for evaluating frontier LLMs on code-mixed Indian language prompts using 123 human participants, covering dataset construction, LLM-as-judge calibration, latency/cost trade-offs, and production deployment lessons.

#AI & ML
a flag on a pole

Benchmarking Frontier LLMs on Indian Vernacular: A 123-Participant Kaggle Study

1. The Problem & Industry Shift

Frontier LLMs are routinely benchmarked on English-centric datasets like MMLU, GSM8K, and HumanEval [1]. These benchmarks have saturated, and the industry has shifted toward human-preference evaluation (Chatbot Arena, MT-Bench) to differentiate models. But there is a structural blind spot: code-mixed, low-resource language prompts — the actual input distribution for hundreds of millions of users in India, Southeast Asia, and Africa.

A user in Bengaluru does not type "What is the capital of Karnataka?" They type Karnataka rajdhani enu? — a Kannada-English code-mix. A user in Chennai types இந்த file-ஐ எப்படி read பண்றது? — Tamil script with English technical nouns. Frontier models trained predominantly on English web data exhibit three failure modes on this distribution:

  1. Script confusion — Transliterating when they should translate, or vice versa.
  2. Register mismatch — Responding in formal literary Tamil when the user wrote colloquial Tanglish.
  3. Hallucinated cultural grounding — Confidently wrong answers about regional facts (e.g., state-specific legal procedures).

Existing benchmarks do not capture this. IndicGLUE [2] is a task benchmark (classification, NER), not a generation benchmark. MMLU has a Hindi translation, but translation destroys code-mixing. The gap is real, and it is why the Kaggle Benchmarking Challenge explicitly rewards human-grounded evaluation.

This article documents a reproducible methodology: 123 participants across 6 Indian states, 1,476 prompts, 4 frontier models, and a calibrated LLM-as-judge pipeline. The goal is not to crown a winner — it is to give engineers a template they can fork for their own locale.

2. Architecture & Core Mechanics

The pipeline has four stages. Each stage is independently versioned and reproducible.

┌──────────────────────────────────────────────────────────────────┐
│ STAGE 1: PROMPT HARVEST (Human-in-the-loop)                      │
│ 123 participants × 12 prompts = 1,476 raw prompts                │
│ Stratified by: state, age, primary language, English fluency     │
│ Collected via: Google Form + WhatsApp voice-note transcription   │
└──────────────────────────┬───────────────────────────────────────┘
                           │
                           ▼
┌──────────────────────────────────────────────────────────────────┐
│ STAGE 2: NORMALIZATION & STRATIFICATION                          │
│ - PII scrubbing (regex + Presidio)                               │
│ - Language ID (fastText lid.176) → code-mix ratio per prompt     │
│ - Bucket into 6 task types: QA, summarization, translation,      │
│   code-switch reasoning, math, cultural knowledge                │
└──────────────────────────┬───────────────────────────────────────┘
                           │
                           ▼
┌──────────────────────────────────────────────────────────────────┐
│ STAGE 3: MODEL INFERENCE (frozen configs)                        │
│ 4 models × 1,476 prompts × 3 temperature samples = 17,712 gens   │
│ Logged: latency_p50/p95, input_tokens, output_tokens, cost_usd   │
└──────────────────────────┬───────────────────────────────────────┘
                           │
                           ▼
┌──────────────────────────────────────────────────────────────────┐
│ STAGE 4: EVALUATION (dual-track)                                 │
│ Track A: Human Likert (1-5) on 15% stratified sample (n=221)     │
│ Track B: LLM-as-judge (GPT-4o + Claude 3.5) on full set          │
│ Calibration: Cohen's κ between Track A and Track B per bucket    │
└──────────────────────────────────────────────────────────────────┘

Why dual-track evaluation? Human evaluation is the ground truth but costs ~$0.40 per rating at Indian annotator rates and does not scale to 17,712 generations. LLM-as-judge scales but has known biases: position bias, verbosity bias, and self-preference bias [3]. The calibration step (Cohen's κ) tells you which task buckets the judge can be trusted on. In our run, κ was 0.71 for QA and translation, but dropped to 0.43 for cultural knowledge — meaning the judge was unreliable exactly where the models were weakest. That is a critical finding: do not trust LLM-as-judge on the tail of your distribution.

Prompt normalization detail. We used fastText lid.176 for language identification, but it fails on short code-mixed strings. We augmented it with a character n-gram script detector (Devanagari, Tamil, Telugu, Kannada, Bengali, Latin) and computed a code_mix_ratio = latin_chars / total_chars. Prompts with ratio between 0.2 and 0.8 were labeled "heavy code-mix" and analyzed separately — this is where model performance diverged most.

3. Production Code Example

Below is the core evaluation harness. It is deliberately framework-agnostic (no LangChain) so you can audit every token. TypeScript with Zod for runtime validation of judge outputs.

import { z } from "zod";
import { createHash } from "crypto";

// --- Schema: judge output must be machine-parseable, not free text ---
const JudgeVerdict = z.object({
  score: z.number().int().min(1).max(5),
  reasoning: z.string().max(500),
  failure_mode: z.enum([
    "none",
    "script_confusion",
    "register_mismatch",
    "hallucination",
    "refusal",
    "truncation",
  ]),
});
type JudgeVerdict = z.infer<typeof JudgeVerdict>;

interface PromptRecord {
  id: string;
  text: string;
  language: string;
  code_mix_ratio: number;
  task_bucket: "qa" | "summarization" | "translation" | "reasoning" | "math" | "cultural";
}

interface Generation {
  prompt_id: string;
  model: string;
  output: string;
  latency_ms: number;
  input_tokens: number;
  output_tokens: number;
  cost_usd: number;
}

// --- Deterministic cache key: same prompt+model+temp = same result ---
function cacheKey(p: PromptRecord, model: string, temp: number): string {
  return createHash("sha256")
    .update(`${p.id}|${model}|${temp}|v1`)
    .digest("hex");
}

// --- Judge call with retry + schema validation ---
// Critical: temperature=0 for judge. Non-determinism here corrupts calibration.
async function judge(
  prompt: PromptRecord,
  generation: Generation,
  judgeModel: string,
  retries = 3
): Promise<JudgeVerdict> {
  const systemPrompt = `You are evaluating an AI response to a code-mixed Indian language prompt.
Score 1-5 on: correctness, language register match, and cultural accuracy.
Return ONLY JSON matching the provided schema. Do not add prose outside JSON.`;

  for (let attempt = 0; attempt < retries; attempt++) {
    const res = await fetch("https://api.openai.com/v1/chat/completions", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
      },
      body: JSON.stringify({
        model: judgeModel,
        temperature: 0, // determinism is non-negotiable for calibration
        response_format: { type: "json_object" },
        messages: [
          { role: "system", content: systemPrompt },
          {
            role: "user",
            content: `PROMPT (${prompt.language}, mix=${prompt.code_mix_ratio.toFixed(2)}):\n${prompt.text}\n\nRESPONSE:\n${generation.output}`,
          },
        ],
      }),
    });

    if (!res.ok) continue;
    const json = await res.json();
    const parsed = JudgeVerdict.safeParse(
      JSON.parse(json.choices[0].message.content)
    );
    if (parsed.success) return parsed.data;
    // Schema mismatch → retry. Do NOT silently coerce.
  }
  throw new Error(`Judge failed after ${retries} attempts for ${generation.prompt_id}`);
}

// --- Cost accounting: track per-bucket spend, not just total ---
// Frontier pricing changes; keep rates in config, not hardcoded.
const RATES: Record<string, { in: number; out: number }> = {
  "gpt-4o": { in: 2.5 / 1e6, out: 10 / 1e6 },
  "claude-3-5-sonnet": { in: 3 / 1e6, out: 15 / 1e6 },
};

function computeCost(model: string, inTok: number, outTok: number): number {
  const r = RATES[model];
  if (!r) throw new Error(`Unknown model: ${model}`);
  return inTok * r.in + outTok * r.out;
}

Engineering decisions worth calling out:

  • temperature: 0 for the judge. If your judge is non-deterministic, your calibration κ is noise. We re-ran 100 judge calls twice and measured 4% disagreement at temp=0.7 vs 0% at temp=0.
  • Schema validation, not regex parsing. Early runs used regex to extract scores and silently mis-parsed 3% of outputs. Zod's safeParse forces an explicit retry path.
  • Cache key includes a version string (v1). When you change the judge prompt, you must invalidate the cache. Forgetting this is the #1 source of irreproducible benchmark results.
  • Cost tracked per generation, not per model. This lets you compute cost-per-correct-answer, which is the metric that actually matters for production routing.

4. Performance, Cost & Trade-offs

Latency and cost (aggregated over 17,712 generations):

Modelp50 latencyp95 latencyAvg output tokensCost / 1k prompts
GPT-4o1.8s6.4s312$4.10
Claude 3.5 Sonnet2.1s7.9s348$6.30
Llama 3.1 70B (self-hosted)0.9s2.2s289$0.42 (A100 amortized)
Gemini 1.5 Flash0.6s1.9s301$0.31

The accuracy/cost frontier is not linear. On the heavy code-mix bucket (ratio 0.2–0.8), the quality gap between GPT-4o and Gemini Flash was 0.4 Likert points (4.1 vs 3.7) — but the cost gap was 13×. For a production chatbot serving 10M queries/month, that is the difference between $3,100 and $41,000. The engineering question is not "which model is best" but "which model is best per bucket."

Where the cheap models actually win: On pure-script prompts (Devanagari, Tamil, no Latin mixing), Gemini Flash matched GPT-4o within 0.1 Likert points. The gap only opened on code-mixed input. This suggests a routing architecture: detect code-mix ratio at the edge, route heavy-mix prompts to frontier models and pure-script prompts to cheap models. We measured a 62% cost reduction with a 0.08 Likert point quality drop using this strategy.

Judge reliability trade-off. As noted, κ dropped to 0.43 on cultural knowledge. The implication: if your benchmark's most important bucket is the one your judge is worst at, you must spend human annotation budget there. We re-allocated 40% of human ratings to the cultural bucket after seeing this — a decision that would have been invisible without per-bucket κ reporting.

Security and privacy. Participant prompts contained phone numbers, addresses, and in 3 cases, Aadhaar-like ID patterns. We ran Microsoft Presidio with a custom Indian PII recognizer before any prompt left the local environment. Do not skip this — sending raw user prompts to a third-party judge API is a data governance incident waiting to happen.

Reproducibility caveat. Frontier model APIs are not versioned in a way that guarantees identical outputs across weeks. We pinned model snapshots where available (gpt-4o-2024-08-06) and logged the system_fingerprint field from every response. Without this, your benchmark is a snapshot of a moving target.

5. Actionable Checklist / Summary

If you are building a locale-specific LLM benchmark for production, do this:

  • Collect prompts from real users, not translators. Translation flattens code-mixing, which is the signal you care about.
  • Stratify by code-mix ratio, not just language. The 0.2–0.8 band is where models fail; analyze it separately.
  • Run dual-track evaluation. Human Likert on a stratified sample, LLM-as-judge on the full set, and report Cohen's κ per bucket.
  • Force temperature: 0 and JSON schema on the judge. Validate with Zod/Pydantic; retry on schema mismatch; never regex-parse.
  • Version your cache keys. Include judge prompt version, model snapshot, and temperature.
  • Track cost per generation and cost per correct answer. Route by bucket, not by global model preference.
  • Scrub PII before any external API call. Use Presidio with locale-specific recognizers.
  • Log system_fingerprint. Frontier APIs drift; your benchmark must record what it actually hit.
  • Publish the raw generations, not just the scores. The community can re-judge; they cannot re-generate.

The headline finding: frontier models are not uniformly weak on Indian vernacular — they are weak specifically on code-mixed input, and the cost of fixing that with a frontier model is 13× the cost of a routing strategy that only escalates when needed. Benchmark the bucket, not the model.

References