Skip to content

bgokden/synthspan

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

8 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

synthspan

CI License: MIT Python deps

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.

Why

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.

Install

pip install synthspan            # or: pip install -e . from source

Quickstart

Run the end-to-end example (examples/quickstart.py) from the repo root:

python examples/quickstart.py
1510 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.

CLI

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-entities to also noise the entities themselves).
  • --balanced — even coverage of templates and records.

Output formats: jsonl (default), conll (BIO), spacy.

Library

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 vs. independent slots

  • 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).

LLM few-shot mode (optional)

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.1
import 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.

Augmenters

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)])

Diversity balancing (optional)

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

How it works

  1. Gazetteer — typed, linked entity records (keeps (city, country) consistent).
  2. Templates — slotted sentences; filling records exact character spans.
  3. Balance — even coverage, de-duplication, per-value caps.
  4. Augment — realistic keyboard-aware typos, with span-preserving recomputation.
  5. Write — JSONL / CoNLL-BIO / spaCy.

Background: what's a gazetteer?

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):

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.

Roadmap

  • More augmenters (casing, punctuation, OCR-style noise, unicode confusables).
  • Direct spaCy DocBin / Hugging Face datasets export.
  • Optional entity normalization (link a surface form to its canonical value).

Development

pip install -e ".[dev]"
pytest

License

MIT © Berk Gökden

About

Generate synthetic labeled-span data for NER / token classification — templates + gazetteers, balancing, and span-preserving typo augmentation. Zero deps.

Topics

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors

Languages