A configurable, evaluation-centric framework for aspect-based sentiment analysis (ABSA).
aspectkit gives researchers one coherent API over the whole ABSA task family — from aspect term extraction to full (aspect, category, opinion, polarity) quadruples with implicit elements — with swappable model backends, loaders for the standard benchmarks, the exact-match evaluation protocol built in, and the corpus-level opinion summary as a first-class output.
fromaspectkitimportABSAabsa=ABSA(task="acos", backend="llm", model="openai:gpt-4o-mini",
categories=["FOOD#QUALITY", "SERVICE#GENERAL"])
absa.predict("The pasta was great but we waited forever.")
# [SentimentTuple(aspect=Span(text='pasta', start=4, end=9), polarity='positive',# category='FOOD#QUALITY', opinion=Span(text='great', start=14, end=19)),# SentimentTuple(aspect=IMPLICIT, polarity='negative', category='SERVICE#GENERAL',# opinion=Span(text='waited forever', start=27, end=41))]- One schema, every subtask. ATE, ATSC, ACD, ACSA, E2E, ASTE, TASD, and ACOS/ASQP are all declarative views (which elements are given, which are predicted) over one canonical tuple — so backends, datasets, and metrics compose instead of forking.
- Implicit aspects and opinions are first-class. Roughly a third of real
review sentiment has no explicit target span;
IMPLICITis part of the data model, distinct from "not annotated". - Evaluation is the point, not an afterthought. Exact-match tuple P/R/F1 (the SemEval comparability standard) is always computed; a lenient token-overlap mode can be reported alongside it; multi-element tasks get a per-element breakdown showing which element drives the misses; gold labels are stripped before the backend ever sees evaluation inputs.
- LLM-era backends without lock-in. One small connector interface spans hosted APIs, OpenAI-compatible local servers, and models already loaded in your notebook.
- Fine-tuning when prompting is not enough. A generative seq2seq backend (the model family holding the published state of the art on exact-match quad extraction) and a trainable ATSC cross-encoder, with plain, transparent training loops.
- The output researchers actually want.
summarize()rolls predictions up into per-aspect sentiment distributions, net scores, and representative quotes — the aspect-based opinion summary ABSA was invented for.
pip install aspectkit # core: zero dependencies
pip install "aspectkit[openai]"# OpenAI + OpenAI-compatible endpoints
pip install "aspectkit[anthropic]"# Anthropic models
pip install "aspectkit[gemini]"# Google Gemini
pip install "aspectkit[transformers]"# local Hugging Face models (+ torch)
pip install "aspectkit[all]"Python 3.10+. The core library (schema, loaders, evaluation, aggregation) has no dependencies; provider SDKs are optional extras imported lazily, so you install only what your chosen backend needs.
Every chat model is addressed the same way — a "provider:model" string or a
live object:
| Spec | Connector |
|---|---|
"openai:gpt-4o-mini" | OpenAI API (OPENAI_API_KEY) |
"anthropic:claude-opus-4-8" | Anthropic API (ANTHROPIC_API_KEY) |
"gemini:gemini-2.0-flash" | Google Gemini (GEMINI_API_KEY) |
"deepseek:deepseek-chat" | DeepSeek (DEEPSEEK_API_KEY) |
"vllm:<served-model>" | local vLLM server (http://localhost:8000/v1) |
"ollama:llama3.1" | local Ollama server |
"mistral:...", "together:...", "groq:...", "openrouter:..." | other OpenAI-compatible providers |
"openai-compatible:<model>" + base_url=... | any OpenAI-protocol endpoint |
"hf:Qwen/Qwen2.5-7B-Instruct" | Hugging Face hub id, loaded as a local pipeline |
a transformerspipeline object | used in-process, as-is |
a (model, tokenizer)pair | used in-process, as-is |
any messages -> strcallable | custom gateways, caching layers, test doubles |
The notebook workflow needs no ceremony — pass the object you already have:
fromtransformersimportpipelinefromaspectkitimportABSApipe=pipeline("text-generation", model="Qwen/Qwen2.5-7B-Instruct")
absa=ABSA(task="aste", backend="llm", model=pipe)Connector behaviour worth knowing: generation defaults are deterministic
(temperature 0 where the provider allows it), JSON schemas are enforced
natively where the provider supports structured output, and protocol-dialect
quirks of OpenAI-compatible servers (unsupported response_format,
max_completion_tokens, rejected temperature) are detected, downgraded
once, and remembered.
For corpus-scale runs against hosted APIs, predict examples in parallel
(order is preserved, failures behave per on_error):
absa=ABSA(task="acos", backend="llm", model="openai:gpt-4o-mini",
concurrency=8, on_error="skip")| name | given | predicted |
|---|---|---|
ate | — | aspect |
atsc (asc, apc) | aspect | polarity |
acd | — | category |
acsa | — | category, polarity |
e2e (atepc) | — | aspect, polarity |
aste | — | aspect, opinion, polarity |
tasd | — | aspect, category, polarity |
acos (asqp, quad) | — | aspect, category, opinion, polarity |
Every published benchmark format has a loader, verified against the official distributions:
fromaspectkitimportload_examplesrest14=load_examples("Restaurants_Train.xml", "semeval2014") # terms/categories viewsmams=load_examples("train.xml", "mams") # same XML schemarest16=load_examples("ABSA16_Restaurants_Train_SB1.xml", "semeval2016")
quads=load_examples("laptop_quad_train.tsv", "acos") # token-span TSVasqp=load_examples("rest15/train.txt", "asqp") # generative-ABSA txttriples=load_examples("train_triplets.txt", "aste") # ASTE-Data-V2tweets=load_examples("train.raw", "twitter") # Dong et al. $T$ formattagged=load_examples("Laptops_Train.atepc", "atepc") # BIO/CoNLL token filesEach loader converts one published format into the canonical schema — spans
become character offsets, NULL/-1,-1 targets become IMPLICIT, integer
polarity codes are mapped per each format's own convention (ACOS 0 is
negative, Twitter 0 is neutral — the loaders know) — so the dataset-version
chaos stays out of your experiment code.
Custom datasets come in through one remappable interface, whatever shape they're in — nested records (one item per text) or flat rows (one opinion per row, the usual CSV/DataFrame layout, grouped by id or text automatically):
fromaspectkit.ioimportfrom_records, from_pandas, from_hf_dataset, read_csv, read_jsonexamples=read_csv("reviews.csv", text="review", aspect="term", polarity="label")
examples=from_pandas(df) # pandas DataFrameexamples=from_hf_dataset(ds["train"]) # Hugging Face datasetsexamples=from_records([{"text": "...", "tuples": [{"aspect": "...", "polarity": "POS"}]}])And back out for analysis: to_pandas(examples) flattens gold data or
predictions into one row per opinion; to_records(...)/read_json round-trip
losslessly.
absa=ABSA(task="acos", backend="llm", model="anthropic:claude-opus-4-8",
categories=CATEGORIES)
absa.fit(train_examples) # seeded few-shot exemplar selection (optional)report=absa.evaluate(test_examples, lenient=True)
print(report)
# EvaluationReport(task=acos, n_examples=583)# exact match P=0.61 R=0.57 F1=0.59 (pred=802, gold=843)# lenient P=0.68 R=0.64 F1=0.66# by element (exact):# aspect P=0.74 R=0.69 F1=0.71# category P=0.81 R=0.76 F1=0.78# opinion P=0.69 R=0.64 F1=0.66# polarity P=0.88 R=0.82 F1=0.85summary=absa.summarize(corpus, by="category", min_mentions=5)
forsinsummary[:3]:
print(s)
# FOOD#QUALITY: n=412, score=+0.55 (negative=71, neutral=44, positive=297)# SERVICE#GENERAL: n=259, score=-0.18 (...)A methodological note baked into the design: prompted LLMs are convenient and
strong at simple polarity, but the benchmark literature consistently shows
them trailing fine-tuned models on exact-match tuple extraction. Validate the
LLM backend on a labelled sample with evaluate() before trusting corpus-level
output — the API makes that the path of least resistance.
For polarity-given-aspect (ATSC), the empirically stronger default is the fine-tuned cross-encoder backend:
absa=ABSA(task="atsc", backend="pair") # yangheng/deberta-v3-base-absa-v1.1When labelled data is available, the fine-tuned route is the strong one. The
seq2seq backend trains a T5/BART-family model to generate linearised tuples
— either MvP-style element markers ([A] pasta [C] FOOD#QUALITY [O] great [S] positive, any task view) or the ASQP paraphrase template ("FOOD#QUALITY is great because pasta is great", quad view):
absa=ABSA(task="acos", backend="seq2seq", model="t5-base",
categories=CATEGORIES) # style="markers" by defaultabsa.fit(train_examples, epochs=20) # plain AdamW loop, seededprint(absa.backend.history_[-1]) # mean loss of the last epochreport=absa.evaluate(test_examples)
absa.backend.save_pretrained("runs/acos-t5") # reload via model="runs/acos-t5"A fresh t5-base knows nothing about the templates: fit before predict.
The same fit() recipe (epochs, learning rate, seeded shuffling, loss
history) fine-tunes the ATSC cross-encoder:
absa=ABSA(task="atsc", backend="pair")
absa.fit(train_examples, epochs=3) # 2e-5 AdamW, the BERT recipefromaspectkitimportABSAExample, SentimentTuple, Span, IMPLICITABSAExample(
text="Would not recommend.",
tuples=[SentimentTuple(aspect=IMPLICIT, # no surface targetcategory="RESTAURANT#GENERAL",
opinion=Span("Would not recommend", 0, 19),
polarity="negative")],
)- Spans carry character offsets
[start, end); offsets are optional, so generative outputs remain first-class citizens. IMPLICIT≠None: implicit means expressed without a surface span;Nonemeans not part of this task's annotation.- An empty
tupleslist means "no opinions", which is not the same as neutral. - Everything round-trips through JSONL (
aspectkit.io.write_jsonl/read_jsonl).
Custom strategies implement the two-method Backend interface (fit,
predict) over canonical examples and plug straight into the facade:
fromaspectkitimportABSAfromaspectkit.backendsimportBackendclassMyBackend(Backend):
...
absa=ABSA(backend=MyBackend(...))Custom chat endpoints implement ChatLLM.complete() — or are just passed as a
callable.
GPL-3.0-or-later — see LICENSE.