# COMBO-NLP

A library for Morphosyntactic Tagging and Dependency Parsing based on [Universal Dependencies](https://universaldependencies.org/).

## Features

- **Multi-task learning**: morphosyntactic tagging (UPOS, XPOS, UFeats), lemmatization, dependency parsing
- **Biaffine attention** for dependency parsing, **character-level seq2seq** lemmatization
- **Two training modes**: full fine-tuning and LoRA (Low-Rank Adaptation)
- **CoNLL 2018 evaluation**: UPOS, XPOS, UFeats, AllTags, Lemmas, UAS, LAS, CLAS, MLAS, BLEX
- **Multi-treebank training**: combine multiple treebanks for the same language
- **Device support**: NVIDIA CUDA, Apple MPS, CPU
- **Full pipeline**: train → export → upload to HuggingFace Hub in one command

## Installation

### uv package manager (if not installed)
macOS / Linux
```bash
curl -LsSf https://astral.sh/uv/install.sh | sh
```
Windows
```powershell
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
```

### Automatic installation
Create a new virtual environment.
```
uv venv
source .venv/bin/activate
```
Install COMBO-NLP.
```
uv pip install combo-nlp
```

### LAMBO segmenter (optional)

A segmenter is only needed when passing raw text strings to COMBO. If you provide pre-tokenized input (`list[str]` or `list[list[str]]`), no segmenter is required.

When you initialize COMBO with a language name (e.g. `COMBO("Polish")`), it automatically loads a [LAMBO](https://gitlab.clarin-pl.eu/syntactic-tools/lambo) segmenter. If LAMBO is not installed, an `ImportError` is raised. LAMBO is hosted on a custom PyPI index and must be installed separately:

```bash
uv pip install --index-url https://pypi.clarin-pl.eu/ lambo
```

Alternatively, add the custom index to your project's `pyproject.toml` so that `lambo` resolves automatically:

```toml
[[tool.uv.index]]
url = "https://pypi.clarin-pl.eu/"
```

### Source installation

Clone the repository and install in a virtual environment.

```bash
git clone https://gitlab.clarin-pl.eu/syntactic-tools/combo-nlp.git
cd combo-nlp
uv venv
source .venv/bin/activate
uv sync
```

## Basic usage

After installing via `pip install combo-nlp`, use COMBO directly in Python:

```python
from combo import COMBO, Language

# Load by language name — automatically downloads model and LAMBO segmenter:
nlp = COMBO("Polish")
nlp = COMBO(Language.POLISH)      # same, using enum

# Load by HuggingFace model ID (no segmenter loaded unless registered):
nlp = COMBO.from_pretrained("clarin-pl/combo-nlp-xlm-roberta-base-polish-pdb-ud2.17")

# Access results:
result = nlp("Ala ma kota.")
for sentence in result:
    for token in sentence:
        print(token.form, token.upos, token.head, token.deprel, token.lemma)
```

### `COMBO()` constructor options

| Parameter | Type | Default | Description |
|---|---|---|---|
| `language` | `str \| Language` | required | Language name (e.g. `"Polish"`) or `Language` enum |
| `device` | `str \| None` | `None` | `"cuda"`, `"cpu"`, `"mps"` or `"auto"`. Auto-detected if `None` (CUDA → MPS → CPU) |
| `batch_size` | `int` | `1` | Sentences processed per forward pass. Higher values improve GPU throughput but use more memory |

### `COMBO.from_predictor()` — custom model

Use this when you have a locally trained or custom checkpoint and want full control over how the model is loaded.

```python
from pathlib import Path
import torch
from combo import COMBO
from combo.inference.predictor import Predictor
from combo.config import ModelConfig

config = ModelConfig(...)  # your model config

predictor = Predictor.from_checkpoint(
    checkpoint_path=Path("path/to/checkpoint.pt"),
    encoders_dir=Path("path/to/encoders/"),
    config=config,
    device=torch.device("cuda"),
)

nlp = COMBO.from_predictor(predictor, batch_size=16)
result = nlp(["Ala", "ma", "kota", "."], tokenized=True)
```

This is useful when you want to:
- load a model **once** and share it across multiple `COMBO` instances,
- use a checkpoint **not published to HuggingFace**,
- control loading details (device, sequence length, beam search, etc.).

Note: `from_predictor` does not attach a LAMBO segmenter — raw text input is not available. Use pre-tokenized input (`tokenized=True`).

| Parameter | Type | Default | Description |
|---|---|---|---|
| `predictor` | `Predictor` | required | A loaded `Predictor` instance |
| `batch_size` | `int` | `1` | Sentences processed per forward pass |

### `nlp(...)` call options

```python
from combo import COMBO, Language, SplitLevel

nlp = COMBO(Language.POLISH)

# --- input types ---

# Raw text string (requires LAMBO segmenter):
result = nlp("Ala ma kota. Kot śpi na kanapie.")

# List of raw sentences — each segmented separately:
result = nlp(["Ala ma kota.", "Kot śpi na kanapie."])

# Pre-tokenized single sentence:
result = nlp(["Ala", "ma", "kota", "."], tokenized=True)

# Pre-tokenized multiple sentences:
result = nlp([["Ala", "ma", "kota", "."], ["Kot", "śpi", "na", "kanapie", "."]], tokenized=True)

# Document from a previous pipeline step:
result = nlp(doc)

# --- segmenter options (raw text only) ---

# split_level: how LAMBO turns are split into sentences
result = nlp(text, split_level=SplitLevel.SENTENCE)  # default — each LAMBO sentence → one Sentence
result = nlp(text, split_level=SplitLevel.TURN)       # each LAMBO turn → one Sentence (useful for dialogue)

# split_multiwords: whether to split multi-word tokens into subtokens
result = nlp(text, split_multiwords=True)   # default — "Zjedlibyśmy" → ["Zjedli", "by", "śmy"]
result = nlp(text, split_multiwords=False)  # keep MWT as single token

# combine both:
result = nlp(text, split_level=SplitLevel.TURN, split_multiwords=False)
```

| Parameter | Type | Default | Description |
|---|---|---|---|
| `input` | `str \| list[str] \| list[list[str]] \| Document` | required | Input to parse (see examples above) |
| `tokenized` | `bool` | `False` | If `True`, treat `list[str]` as pre-tokenized tokens of one sentence |
| `split_level` | `SplitLevel \| str \| None` | `None` (→ `SENTENCE`) | Sentence splitting granularity — `SENTENCE` or `TURN` |
| `split_multiwords` | `bool \| None` | `None` (→ `True`) | Whether to split multi-word tokens into subtokens |

### Accessing results

```python
result = nlp("Ala ma kota.")

for sentence in result:
    print(sentence.text)
    for token in sentence.tokens:
        print(
            token.id,       # token index ("1", "2", ...; "1-3" for MWT ranges in CoNLL-U)
            token.form,     # surface form
            token.lemma,    # lemma
            token.upos,     # universal POS tag
            token.xpos,     # language-specific POS tag
            token.feats,    # morphological features (dict)
            token.head,     # head token index (int)
            token.deprel,   # dependency relation
            token.misc,     # misc field, e.g. "SpaceAfter=No"
        )

# Export to CoNLL-U:
print(result.to_conllu())
```

## Training & Evaluation

### Environment Setup

Copy `.env.example` to `.env` and fill in your API keys:

```bash
cp .env.example .env
```

- **`WANDB_API_KEY`** — from https://wandb.ai/authorize (experiment tracking)
- **`HF_TOKEN`** — from https://huggingface.co/settings/tokens (uploading models)

### Training

```bash
combo-nlp-train --task-config config/tasks/combo-nlp-xlm-roberta-base-polish-pdb-ud2.17.yaml

# Resume from checkpoint:
combo-nlp-train --task-config config/tasks/combo-nlp-xlm-roberta-base-polish-pdb-ud2.17.yaml \
    --resume /path/to/checkpoint_latest.pt
```

### Evaluation

Evaluation uses the official CoNLL 2018 script. The model can be loaded from HuggingFace Hub (`--model`), a local directory (`--model-dir`), or a training checkpoint (`--task-config` + `--checkpoint`).

```bash
# Evaluate a HuggingFace model (aligned, gold tokenization):
combo-nlp-evaluate --model clarin-pl/combo-nlp-xlm-roberta-base-polish-pdb-ud2.17 \
    --test-file /path/to/test.conllu

# Full-text evaluation (end-to-end with LAMBO segmentation):
combo-nlp-evaluate --model clarin-pl/combo-nlp-xlm-roberta-base-polish-pdb-ud2.17 \
    --test-file /path/to/test.conllu --full-text --language Polish

# Evaluate a training checkpoint:
combo-nlp-evaluate --task-config config/tasks/combo-nlp-xlm-roberta-base-polish-pdb-ud2.17.yaml

# Compare two CoNLL-U files directly:
combo-nlp-evaluate \
    --gold-file /path/to/gold.conllu \
    --predictions-file /path/to/predictions.conllu
```

### Prediction

```bash
combo-nlp-predict --model-dir /path/to/model --text "Ala ma kota ."

# Parse a CoNLL-U file:
combo-nlp-predict --model-dir /path/to/model \
    --input input.conllu --output output.conllu
```

## Configuration

Two-level config system:

- **`config/base.yaml`** — shared defaults (architecture, hyperparameters, training settings)
- **`config/tasks/<task>.yaml`** — task-specific overrides (language, treebanks, base model)

Task configs are deep-merged on top of base config — only specify fields that differ. To add a new language, create a task config and train.

## Full Pipeline

Train, export, and upload to HuggingFace Hub in one command:

```bash
combo-nlp-pipeline --task-config config/tasks/combo-nlp-xlm-roberta-base-polish-pdb-ud2.17.yaml

# Dry run:
combo-nlp-pipeline --task-config config/tasks/combo-nlp-xlm-roberta-base-polish-pdb-ud2.17.yaml --dry-run
```

Steps: **Train** (fine-tune with per-epoch evaluation) → **Export** (package model, generate model card) → **Upload** (push to HuggingFace Hub).

## CLI Commands

| Command | Description |
|---|---|
| `combo-nlp-train` | Train a model |
| `combo-nlp-evaluate` | Evaluate a model |
| `combo-nlp-predict` | Run predictions |
| `combo-nlp-export` | Export a trained model |
| `combo-nlp-pipeline` | Full train → export → upload pipeline |

## License

## Citations
