Generate synthetic labeled data for NER / token classification — cheaply, at scale, and with exact spans. Feed it a small gazetteer of entities and a few templates, and it produces thousands of labeled examples, balanced and optionally noised with realistic typos.
Zero runtime dependencies. Deterministic with a seed. Exports to JSONL, CoNLL/BIO, and spaCy.
This is the data-generation approach behind the multilingual place-extractor models at huggingface.co/Berk, served via place-extractor-mcp.
Labeled NER data is expensive to annotate. But for many entity types you already
have a list (cities, products, drug names, tickers…) and know the shapes
of sentences they appear in. synthspan turns those into training data with
guaranteed-correct spans — no manual labeling.
pip install synthspan # or: pip install -e . from sourceRun the end-to-end example (examples/quickstart.py) from
the repo root:
python examples/quickstart.py1510 examples
labels: {'CITY': 1510, 'COUNTRY': 1510}
sample: The conference Will Be Held in Rotterdam (Netherlands) this yEar.
-> [('Rotterdam', 'CITY'), ('Netherlands', 'COUNTRY')]
wrote data.jsonl and data.conll
It generates from a gazetteer + templates, adds typo/case/OCR noise (spans stay correct), de-duplicates, and writes both offset (JSONL) and BIO (CoNLL) datasets.
synthspan generate \
--entities examples/entities.csv \
--templates examples/templates.txt \
-n 10000 --balanced --dedupe \
--typo-rate 0.05 --seed 42 \
--format jsonl --out data.jsonl--entities— CSV whose header names the labels; each row is a linked record so combinations stay consistent (Amsterdam,Netherlands).--templates— one per line, with{LABEL}slots.- augmenters —
--typo-rate,--case-rate,--ocr-rate,--punct-rate; spans are recomputed so labels stay correct (add--typo-entitiesto also noise the entities themselves). --balanced— even coverage of templates and records.
Output formats: jsonl (default), conll (BIO), spacy.
import random
from synthspan import Gazetteer, Template, generate, augment, dedupe, to_jsonl
# A gazetteer of linked (city -> its own country) records. Bring your own list;
# the more pairs, the more variety. 12 rows ship in examples/entities.csv.
gaz = Gazetteer.from_csv("examples/entities.csv")
# CO-LOCATION templates: the city is IN the country, so linked pairs stay correct.
templates = [
Template("{CITY} is a city in {COUNTRY}."),
Template("We spent a few days in {CITY}, {COUNTRY}."),
Template("The conference will be held in {CITY} ({COUNTRY}) this year."),
]
rng = random.Random(42)
data = generate(gaz, templates, n=2000, rng=rng) # linked (default): city + ITS country
data = augment(data, rate=0.06, rng=rng) # typos add surface variety; spans stay correct
data = dedupe(data) # drop exact duplicates (post-typo)
print(data[0].entities()) # [('Amsterdam', 'CITY'), ('Netherlands', 'COUNTRY')]
open("data.jsonl", "w").write(to_jsonl(data))Each example is {"text": ..., "spans": [{"start", "end", "label", "text"}, ...]}.
Distinct base sentences ≈ records × templates; typos multiply the surface
variety, and a large gazetteer scales it up — that's how a short list becomes a
lot of labeled data.
linked=True(default) — every slot in a template is filled from one record, so co-located mentions stay consistent. Use for templates where the city is in that country:"{CITY}, {COUNTRY}"→ Amsterdam, Netherlands. Unique outputs ≈ records × templates.linked=False— each slot is sampled independently. Use for relational templates where the places differ:"from {CITY} to {COUNTRY}"→ Amsterdam → Japan. Unique outputs ≈ cities × countries × templates (lots of cheap data).
Pick the mode that matches your template's meaning. On the CLI: add
--independent for the second mode. Use dedupe() / --dedupe when you want
only distinct texts (note: after typo augmentation most texts are already
unique).
Want more natural variety than templates? Use a local model with structured output. The model selects a coherent combination from your gazetteer and writes a sentence; synthspan aligns the entities back to exact spans — the model never emits offsets, so spans can't be hallucinated.
Default backend is Ollama (no Python dependency — just an HTTP call to localhost):
ollama pull llama3.1import random
from synthspan import Gazetteer
from synthspan.llm import OllamaBackend, llm_generate
gaz = Gazetteer.from_csv("examples/entities.csv")
backend = OllamaBackend(model="llama3.1") # local, JSON-schema-constrained output
data = llm_generate(gaz, backend, n=500, rng=random.Random(0), skip_empty=True)
print(data[0].text, data[0].entities())Backends: OllamaBackend (local HTTP, zero deps) · LlamaCppBackend (GGUF grammars,
pip install synthspan[llama-cpp]) · or implement complete(prompt, schema) -> dict
for vLLM / any OpenAI-compatible endpoint. FakeBackend ships for offline tests.
Augmentation is pluggable — an augmenter is just (text, rng) -> str. Built-ins:
typos, random_case, punctuation, ocr. Compose any of them; the framework
recomputes spans so labels never drift (entities are left clean by default).
from synthspan import apply, typos, random_case, ocr
noisy = apply(data, [typos(0.05), random_case(0.1), ocr(0.03)], rng=rng)Adding your own is a few lines:
def shout(rate=0.1):
def f(text, rng):
return "".join(c.upper() if rng.random() < rate else c for c in text)
return f
apply(data, [shout(0.2)])Count-based balancing (cap_per_value) evens out values; semantic balancing
evens out phrasings. Bring any local embedder and cluster — pure-Python k-means,
zero dependencies:
from synthspan import cluster_balance
embed = lambda text: my_local_model.encode(text) # -> list[float]
balanced = cluster_balance(data, embed, k=8) # even coverage across 8 clusters- Gazetteer — typed, linked entity records (keeps
(city, country)consistent). - Templates — slotted sentences; filling records exact character spans.
- Balance — even coverage, de-duplication, per-value caps.
- Augment — realistic keyboard-aware typos, with span-preserving recomputation.
- Write — JSONL / CoNLL-BIO / spaCy.
A gazetteer is a curated list of known entity names — originally a geographical directory of place names, and in NLP any dictionary of entity surface forms (cities, drugs, companies, tickers, genes…). Gazetteers are a long-standing signal for named entity recognition (NER):
- Towards Improving Neural Named Entity Recognition with Gazetteers — Liu et al., ACL 2019
- Gazetteer-Enhanced Attentive Neural Networks for NER — Lin et al., EMNLP-IJCNLP 2019
- Soft Gazetteers for Low-Resource Named Entity Recognition — Rijhwani et al., ACL 2020
- Gazetteer — Wikipedia
Those works feed a gazetteer into the model. synthspan uses it the other way
around: a gazetteer + templates generate the labeled training data itself — so
you can train a standard token classifier without hand annotation.
- More augmenters (casing, punctuation, OCR-style noise, unicode confusables).
- Direct spaCy
DocBin/ Hugging Facedatasetsexport. - Optional entity normalization (link a surface form to its canonical value).
pip install -e ".[dev]"
pytestMIT © Berk Gökden