Blog
Sep 8, 2026-7 MIN READ
Identifying the Raga from the Notes

Identifying the Raga from the Notes

A weighted heuristic gets the raga right 67.3% of the time. Reranking against a corpus of published compositions takes it to 72.7%, which is the version I can actually ship — and on a 55-composition eval that number carries an interval wide enough to be worth writing down. Here is the method and the number, including the unflattering parts.

By Baljeet Singh

A raga isn't a scale. It's a set of permitted notes, plus a hierarchy among them, plus characteristic phrases, plus rules about how you ascend and descend. Two ragas can share every note and still be different ragas.

In my notation app, users label their compositions with a raga. Many don't, or pick the wrong one. So: given the notes, can you tell which raga this is?

This is a classification problem with about 31 classes in my data, roughly 55 labelled examples, and nine classes with exactly one example. Those numbers are the whole story, and I will come back to them.

Six signals, weighted

The first version has no learning in it. It scores every candidate raga against the notation on six signals:

  1. Note set — frequency-weighted precision, set recall, and a penalty for notes the raga forbids
  2. Vadi and samvadi — does the raga's dominant and sub-dominant note actually dominate, by rank and by frequency mass
  3. Variant usage — komal and tivra versus shuddh, frequency-weighted, because that is often the only difference between two ragas
  4. Pakad matching — bigrams and trigrams from the raga's characteristic phrase, IDF-weighted so a phrase shared by many ragas counts for less
  5. Phrase boundaries — do the notes landing on beat boundaries match vadi and samvadi
  6. Movement — ascending bigrams checked against aroh, descending against avroh

All the blend factors sit in one exported DEFAULT_TUNING object rather than scattered through the scoring, so calibrating is editing one place.

Leave-one-out top-1: 67.3%. Top-3: 94.5%. Top-5: 98.2%.

The gap between top-1 and top-3 is the interesting part. The heuristic almost always knows the answer is in a small set; it is the final discrimination between near-identical ragas that it gets wrong.

The rerank

If the heuristic narrows to three and fumbles the last step, the obvious help is examples. Other compositions in this raga exist. Do they look like this one?

Each composition gets a fingerprint: three blocks — note unigrams, bigrams and trigrams — each normalised to sum to 1.

addBlock('u', Object.entries(data.freq));
addBlock('b', data.bigrams);
addBlock('t', data.trigrams);

Similarity is the Bhattacharyya coefficient, Σ√(pᵢqᵢ), computed per block and averaged:

/**
 * Per block so the dozen unigrams don't drown the phrase blocks;
 * square-rooted so a few dominant n-grams don't drown the rest.
 */

Both halves of that comment are load-bearing. Averaging per block stops twelve unigrams outvoting hundreds of trigrams. Square-rooting the distributions stops the handful of very frequent notes — every composition is full of s and p — from dominating a measure that is supposed to be about phrasing.

Then the corpus vote is blended with the heuristic score, with three calibrated knobs:

export const DEFAULT_CORPUS_RERANK = {
  alpha: 0.12,    // weight of the corpus vote against the heuristic
  tau: 0.05,      // softmax temperature; lower → closer to a 1-NN vote
  nullSim: 0.75,  // similarity of an implicit "no match" class
};

nullSim is the one I would point at. With a small corpus, most ragas have no labelled example at all, and without a null class every composition gets dragged toward whichever few ragas the corpus happens to hold. Giving "none of these" a fixed similarity — set at roughly the typical best wrong raga similarity — means a raga only wins the vote if its nearest labelled composition is genuinely closer than chance.

A small corpus that knows when to abstain beats a small corpus that always votes.

The numbers, all of them

Leave-one-out over 55 compositions, 31 ragas, 9 of them with a single example:

Configurationtop-1top-3top-5raga present in corpus
Heuristic only67.3%94.5%98.2%
+ library, public only, cap 1072.7%94.5%98.2%52.7%
+ library, all labelled, cap 1083.6%94.5%100%83.6%
+ library, all labelled, no cap83.6%94.5%100%83.6%

Four things worth reading off that table, including the ones I would rather not write.

The last column bounds what reranking can add, not what the system can score. Reranking can't find a raga the corpus has never seen — but the reranker blends its score with the heuristic's rather than replacing it, so coverage caps the rerank's contribution, not the final number. The shippable row is the proof: 52.7% coverage, 72.7% top-1. The heuristic alone scores 67.3% with no corpus at all, and that floor doesn't go away when the corpus is thin.

Worth resisting a reading I nearly published. With every labelled composition available, coverage is 83.6% and top-1 is 83.6%, which looks like the rerank converting all of its opportunities. It isn't — those are two equal numbers, not a conversion rate. 83.6% coverage is the 46 non-singleton compositions and 83.6% top-1 is 46 correct out of 55, but nothing says they are the same 46. For that reading to hold, every covered composition would have to be right and all 9 singletons wrong, which against a 37/55 heuristic baseline would mean reranking broke singletons the heuristic already had. Equal percentages are a coincidence worth checking, not a finding.

But I can only ship the public-only version. Reranking against every labelled composition means shipping fingerprints built from private ones, and I won't do that (below). The corpus I can actually ship contains the right raga for 52.7% of compositions, and delivers 72.7%. The 83.6% is real, measured, and unshippable — an upper bound on what a larger public corpus would buy, not a number I get to quote.

Capping does nothing here. Cap 10, cap 5, no cap are identical, because almost no raga has more than a handful of examples. That knob is waiting for a dataset that doesn't exist yet.

Top-3 never moves. 94.5% across every configuration. The rerank reorders within the top few; it doesn't rescue the compositions the heuristic never shortlisted. Those are a different failure and need a different fix.

The sample is small and I should say so

55 compositions. 31 ragas. Nine ragas with exactly one example, which under leave-one-out means those nine are unsolvable by the corpus path by construction — remove the one example and the raga has no representation at all.

83.6% of 55 is 46 compositions. The 95% Wilson interval on 46/55 runs from roughly 71% to 91%. So "84%" is really "somewhere in the seventies to low nineties, probably", and the honest way to write it is with the interval attached.

Any of these numbers moves by almost two percentage points if a single composition flips. Treat them as directional.

Why the corpus is built only from published compositions

There's a privacy constraint here that took me a while to see clearly, and it changed the design.

A fingerprint can be inverted back into the melody. It's a normalised distribution over note unigrams, bigrams and trigrams. Given the trigram distribution of a composition, reconstructing a recognisable melody isn't hard — n-gram models are generative, that is what they are for. A fingerprint is not an anonymised summary. It is a lossy but musically informative encoding of someone's unpublished work.

So the shipped corpus is built only from published compositions — material the author has already made public. The private ones improve the eval and never ship.

It costs real accuracy: 83.6% down to 72.7%, eleven points. I would rather pay eleven points than ship a file that leaks subscribers' unpublished melodies to anyone who runs npm view.

The corpus is also loaded lazily, so the data only reaches users who actually click Identify.

The eval is the artifact

The harness matters more than any single figure.

It runs leave-one-out with the production functions and the same library builder the generator script uses — not a reimplementation. An eval that quietly diverges from the shipped code measures something nobody ships.

It excludes the composition being scored, and also any identical twin saved under a different id:

const corpus = lib.filter(
  (e) => e.id !== p.r.id && fingerprintSimilarity(p.fp, e.fingerprint) < 0.9999
);

Without that filter, a composition duplicated under two ids scores itself against its own copy and the accuracy is a lie. I found that because a number looked too good.

It prints every configuration on every run, so the comparison is always in front of you rather than reconstructed from memory of a good run.

A note on a number I have been quoting

My own CV says this work lifted top-1 accuracy "from 59.5% to 72.7%". Re-running the eval today, the heuristic-only baseline is 67.3%, not 59.5%.

Both were true when measured. The heuristic improved after the rerank landed, and the dataset has grown since. But the honest version today is 67.3% → 72.7% shipped, 83.6% with everything labelled — a smaller delta for the rerank than the one I have been quoting, because the baseline it is measured against got better.

That is the argument for running the eval before you cite it, rather than citing the run you remember.

© 2019-2026 Baljeet Singh. All rights reserved.