Writing · · 6 min read
Nepali–English Code-Switching
Everyday Nepali is Nepali with English nouns, English verbs, and English punctuation. Why that is structure, not contamination — and what it does to models.
#nepali#language#code-switching#nlp
A Nepali sentence that is purely Nepali is a literary object. In ordinary speech, the sentence is Nepali with English material placed inside it at specific points, and those points are not arbitrary. English supplies the nouns and the technical terms. Nepali supplies the grammar. The mix is systematic.
This is called code-switching. This post starts from a one-line language identifier, shows where it breaks, and rebuilds the pipeline step by step: span labels, a script-independent classifier for Roman Nepali, splitting mixed words at the morpheme boundary, and an evaluation that rewards the sentence people actually say. All code is plain Python.
The simplest model: one sentence, one language
The naive model assumes each sentence belongs to exactly one language. A classifier labels it Nepali or English. A tokenizer applies one vocabulary. An evaluator draws samples from one corpus.
The simplest program written against that model is a script test:
def naive_label(sentence: str) -> str:
if any("ऀ" <= ch <= "ॿ" for ch in sentence):
return "ne"
return "en"Consider a plausible university sentence:
आज मेरो assignment बुझाउनुपर्ने थियो। āja mero assignment bujhāunuparne thiyo The assignment was due today.
The script rule labels the sentence Nepali because Devanagari is present. A word-level English dictionary sees assignment and votes the other way. Neither is wrong about its unit; both are wrong about the sentence.
Now write the same sentence the way it is often typed on a phone:
aaja mero assignment bujhaunuparne thiyo
The script rule now says English. Every token is Latin. The sentence has not changed language; it has changed keyboard.
Deriving the structure: matrix language and embedded material
The pattern is consistent across domains. A whole layer of institutional vocabulary is English: syllabus, exam, marks, fee, hostel. Medical terms are English. Computing terms are English. The words for a phone, a charger, a screen are English. The verbs for using them are Nepali.
The structure follows from a division of labour inside a bilingual community. One language provides the frame. The other provides content words the frame lacks a routine term for. Linguists call the frame the matrix language and the inserted material embedded (the Matrix Language Frame model, Myers-Scotton, 1993).
- The matrix language provides the frame. Nepali provides word order, agreement, case, and tense.
- Embedded nouns take native morphology. An English noun acquires Nepali postpositions: assignment-lāī, hostel-mā, exam-ko.
- Verbs are borrowed through a light verb. The English part is a bare stem; Nepali garnu (to do) carries tense and agreement.
- The switch happens at content morphemes, not inside function words. The sentence does not switch in the middle of a tense marker.
| surface form | English part | Nepali part | gloss |
|---|---|---|---|
| hostel-mā | hostel | -mā (in) | in the hostel |
| exam-ko | exam | -ko (of) | of the exam |
| download garyo | download | garyo (did) | downloaded |
| forward gardinus | forward | gardinus (please do) | please forward |
| charge garna | charge | garna (to do) | to charge |
This is not a deficit of Nepali. It is a stable bilingual arrangement, and a model meant for Nepali speakers has to treat it as the ordinary case.
Step 1: label spans, not sentences
Bottleneck: a sentence-level label cannot represent a sentence that alternates. Downstream steps inherit the error: the tokenizer applies the wrong vocabulary, translation replaces the loanword with a formal synonym no one uses, and evaluation samples miss the target distribution.
Fix: label each token, then treat the sentence as a sequence of labelled spans.
import re
TOKEN = re.compile(r"[A-Za-z]+|[ऀ-ॿ]+")
def span_label(text: str, classify_latin) -> list[tuple[str, str]]:
spans = []
for tok in TOKEN.findall(text):
if re.match(r"[ऀ-ॿ]", tok):
spans.append((tok, "ne")) # Devanagari is Nepali here
else:
spans.append((tok, classify_latin(tok))) # Latin needs a real decision
return spans
def matrix_language(spans: list[tuple[str, str]]) -> str:
# Approximation: the label carrying most tokens is the frame.
from collections import Counter
return Counter(lang for _, lang in spans).most_common(1)[0][0] if spans else "unknown"Devanagari tokens are easy. The whole difficulty has moved into classify_latin: deciding whether mero is English or Nepali.
Step 2: classify Latin tokens by character n-grams
Bottleneck: Roman Nepali and English share an alphabet. A dictionary of English words fails on misspellings, and a dictionary of Roman Nepali fails because there is no standard spelling: aaja, aja, and aj all mean today.
Fix: classify by character n-grams. Nepali romanization has a distinctive texture — aa, chh, -nu, -ko, -ma, -lai, -yo endings — that survives spelling variation.
import math
from collections import Counter
def ngrams(word: str, n: int = 3) -> list[str]:
w = f"<{word.lower()}>"
return [w[i:i + n] for i in range(len(w) - n + 1)]
class NgramNB:
"""Naive Bayes over character trigrams, with add-one smoothing."""
def fit(self, words_by_lang: dict[str, list[str]]):
self.counts = {lang: Counter(g for w in ws for g in ngrams(w))
for lang, ws in words_by_lang.items()}
self.totals = {lang: sum(c.values()) for lang, c in self.counts.items()}
self.vocab = len(set().union(*self.counts.values()))
return self
def __call__(self, word: str) -> str:
def score(lang):
c, t = self.counts[lang], self.totals[lang]
return sum(math.log((c[g] + 1) / (t + self.vocab)) for g in ngrams(word))
return max(self.counts, key=score)
lid = NgramNB().fit({
"ne": "aaja aja mero timro bujhaunuparne thiyo garnu garyo gardinus hostelma "
"kasto chha chhaina bhayo khana jaanu aayo ramro sanga lai ko ma".split(),
"en": "assignment syllabus exam marks fee hostel download forward charger "
"today submit phone screen class teacher result notice".split(),
})
print(span_label("aaja mero assignment bujhaunuparne thiyo", lid))
# [('aaja', 'ne'), ('mero', 'ne'), ('assignment', 'en'),
# ('bujhaunuparne', 'ne'), ('thiyo', 'ne')]A list of a few dozen words is a toy; a real classifier is trained on thousands of labelled tokens and usually adds the neighbouring tokens as context, because short words like ma and to are ambiguous in isolation. The structure stays the same: features that describe how a word is spelled, not which dictionary it appears in.
Step 3: split mixed words at the morpheme boundary
Bottleneck: hostelma and assignmentlai are single tokens that are half English, half Nepali. A word-level label must pick one and is wrong either way.
Fix: peel known Nepali postpositions off Latin tokens and label the stem separately.
SUFFIXES = ["haru", "lai", "ko", "ka", "ki", "ma", "bata", "le", "sanga"]
def split_suffix(token: str, lid) -> list[tuple[str, str]]:
low = token.lower()
for suf in sorted(SUFFIXES, key=len, reverse=True):
stem = low[: -len(suf)]
if low.endswith(suf) and len(stem) >= 3 and lid(stem) == "en":
return [(token[: len(stem)], "en"), (token[len(stem):], "ne")]
return [(token, lid(token))]
print(split_suffix("hostelma", lid)) # [('hostel', 'en'), ('ma', 'ne')]
print(split_suffix("assignmentlai", lid)) # [('assignment', 'en'), ('lai', 'ne')]The guard lid(stem) == "en" matters. Without it, Nepali words that happen to end in -ma or -ko get split too. The same routine applies to Devanagari loanwords such as होस्टेलमा, with a Devanagari suffix list and a transliteration step before the stem check.
Step 4: build the corpus from the target distribution
Bottleneck: formal Nepali corpora underrepresent code-switching. A benchmark written in newspaper register measures a language that exists mostly in print. A model that scores well on it can fail on the sentences people produce.
Fix: sample from the registers where code-switching occurs, record the mix, and keep the variants.
from collections import Counter
SOURCES = ["social_media", "messaging", "spoken_transcript", "news_formal"]
def mix_report(rows: list[dict]) -> dict[str, float]:
counts = Counter(r["source"] for r in rows)
total = sum(counts.values()) or 1
return {s: counts.get(s, 0) / total for s in SOURCES}
def script_report(rows: list[dict]) -> dict[str, float]:
# Devanagari only, Latin only, or both in one sentence.
def script(text):
dev = any("ऀ" <= c <= "ॿ" for c in text)
lat = any(c.isascii() and c.isalpha() for c in text)
return "mixed" if dev and lat else "devanagari" if dev else "latin"
counts = Counter(script(r["text"]) for r in rows)
return {k: v / max(len(rows), 1) for k, v in counts.items()}Three rules follow.
- Include Roman Nepali. Much Nepali online is typed in Latin script. A system that only handles Devanagari misses a large share of the language as it is written.
- Keep spelling variants. assignment, assigment, and asainment all occur. Map them to one canonical form for counting, but train on the variants.
- Report the mix. A corpus statistic that does not say what fraction is Roman, Devanagari, and mixed cannot be compared with another.
Step 5: evaluate on natural text, and reward the loanword
Bottleneck: translation systems tuned on monolingual data produce formal output for informal input. They map assignment to a formal Nepali coinage, producing a sentence that sounds translated rather than spoken. The loanword was the correct choice.
Fix: score loanword preservation separately from overlap, and report both.
def loanword_preservation(pred: str, ref: str, loanwords: set[str]) -> float | None:
# Of the loanwords the reference keeps, what fraction does the prediction keep?
kept = [w for w in loanwords if w in ref.lower().split()]
if not kept:
return None
return sum(w in pred.lower().split() for w in kept) / len(kept)
def code_switch_rate(sentences: list[list[tuple[str, str]]]) -> float:
mixed = sum(1 for s in sentences if len({lang for _, lang in s}) > 1)
return mixed / max(len(sentences), 1)
def eval_gap(formal_acc: float, natural_acc: float) -> float:
# A large gap signals distribution mismatch, not model weakness.
return formal_acc - natural_accTokenization is part of the evaluation. Measure tokens per word separately for Devanagari, Roman Nepali, and mixed spans. A tokenizer that splits bujhaunuparne into eight pieces and assignment into one makes the Nepali half of every sentence the expensive half.
What to measure
- Code-switch rate per thousand sentences. It predicts how much a monolingual system will degrade.
- Span accuracy, scored against human token labels, reported separately for Devanagari and Latin tokens.
- Suffix-split accuracy on mixed tokens such as hostelma.
- Loanword preservation in translation output.
- Fertility by script: tokens per word on Devanagari, Roman Nepali, and mixed spans.
- Evaluation gap: formal benchmark accuracy minus natural-text accuracy.
A checklist
- Label spans, not sentences. A sentence label erases the structure the system needs.
- Classify Latin tokens by spelling texture, not by dictionary lookup.
- Split mixed tokens at the postposition, and guard the split with the classifier.
- Include Roman Nepali and keep its spelling variants.
- Evaluate on social, messaging, and spoken-adjacent text, not only newspaper prose.
- Reward the loanword when the reference keeps it.
- Report the code-switch rate and script mix beside every accuracy number.
The sentence with assignment in it is not corrupted Nepali. It is Nepali doing its ordinary work, with the vocabulary its speakers use. A model that cannot read it cannot read Nepali.