Skip to content

Repository files navigation

aspectkit

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.

from aspectkit import ABSA

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

Why aspectkit

  • 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; IMPLICIT is 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.

Installation

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.

Connecting a model

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 transformers pipeline object used in-process, as-is
a (model, tokenizer) pair used in-process, as-is
any messages -> str callable custom gateways, caching layers, test doubles

The notebook workflow needs no ceremony — pass the object you already have:

from transformers import pipeline
from aspectkit import ABSA

pipe = 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")

Tasks

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

Loading data: benchmarks, custom files, frameworks

Every published benchmark format has a loader, verified against the official distributions:

from aspectkit import load_examples

rest14 = load_examples("Restaurants_Train.xml", "semeval2014")   # terms/categories views
mams   = load_examples("train.xml", "mams")                      # same XML schema
rest16 = load_examples("ABSA16_Restaurants_Train_SB1.xml", "semeval2016")
quads  = load_examples("laptop_quad_train.tsv", "acos")          # token-span TSV
asqp   = load_examples("rest15/train.txt", "asqp")               # generative-ABSA txt
triples = load_examples("train_triplets.txt", "aste")            # ASTE-Data-V2
tweets = load_examples("train.raw", "twitter")                   # Dong et al. $T$ format
tagged = load_examples("Laptops_Train.atepc", "atepc")           # BIO/CoNLL token files

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

from aspectkit.io import from_records, from_pandas, from_hf_dataset, read_csv, read_json

examples = read_csv("reviews.csv", text="review", aspect="term", polarity="label")
examples = from_pandas(df)                       # pandas DataFrame
examples = from_hf_dataset(ds["train"])          # Hugging Face datasets
examples = 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.

Few-shot, evaluation, and the corpus summary

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.85

summary = absa.summarize(corpus, by="category", min_mentions=5)
for s in summary[: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.1

Fine-tuning

When 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 default

absa.fit(train_examples, epochs=20)           # plain AdamW loop, seeded
print(absa.backend.history_[-1])              # mean loss of the last epoch

report = 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 recipe

The data model

from aspectkit import ABSAExample, SentimentTuple, Span, IMPLICIT

ABSAExample(
    text="Would not recommend.",
    tuples=[SentimentTuple(aspect=IMPLICIT,                 # no surface target
                           category="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; None means not part of this task's annotation.
  • An empty tuples list means "no opinions", which is not the same as neutral.
  • Everything round-trips through JSONL (aspectkit.io.write_jsonl / read_jsonl).

Extending

Custom strategies implement the two-method Backend interface (fit, predict) over canonical examples and plug straight into the facade:

from aspectkit import ABSA
from aspectkit.backends import Backend

class MyBackend(Backend):
    ...

absa = ABSA(backend=MyBackend(...))

Custom chat endpoints implement ChatLLM.complete() — or are just passed as a callable.

License

GPL-3.0-or-later — see LICENSE.

About

A ready-to-use Python framework for automated aspect-based sentiment analysis employing a variety of flexible tools that span from originally proposed ones to those viable in the post-LLM era

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages