Discord

Checklist OAI 2024 Stage I · Task 3

Dependency Parsing

Polish title: Analiza zależnościowa

Build dependency trees for Polish sentences from HerBERT word vectors by training small models that predict tree distances and depths.

  • NLP
  • Dependency parsing (structured prediction)
  • Polish original · English translation

The task

The way words in a sentence depend on one another forms a rooted tree. The task is to construct such dependency trees automatically for Polish sentences, using vectors produced by the HerBERT language model (allegro/herbert-base-cased) instead of classical left-to-right parsers.

The solution must split each sentence into subwords, assign each subword a final or intermediate HerBERT vector, aggregate subword vectors into word vectors, train simple models that predict the pairwise tree distance between words and the depth of each word, and finally use the two models to build a valid tree with exactly len(sentence) − 1 edges (ParsedSentence.from_edges_and_root may be used).

Training data consist of 1,000 annotated sentences in CoNLL format (train.conll) and 200 validation sentences (valid.conll); helper code for reading and visualising trees is provided in utils.py.

Abridged and translated by SOTA from the official Polish materials. The official statement has the exact rules, and it wins wherever this summary differs.

In English

This task was published in Polish. SOTA translated it into English on 16 September 2026. Only the words changed in the notebooks: markdown, code comments, messages and printed output. The code, file names and paths are the original's, so a translated notebook runs with the original data.

Read the task notebook in English 1279 words and 14 code cells

Dependency parsing

image.png

Introduction

The language we use every day works on the principle of compositionality. This means that the meaning of complex linguistic expressions can be inferred from their component parts and from the relations between them. This property gives language users far-reaching creativity in how they construct utterances, while preserving the precision of communication. The way in which the words in a sentence are connected to one another forms the structure of a rooted tree. The problem we consider in this task is the automatic construction of such trees for sentences in Polish. The problem is called syntactic parsing of sentences; specifically, we will be performing dependency parsing.

Syntactic parsing is difficult in general. For example, although the sentences (1) Maria do jutra jest zajęta. ("Maria is busy until tomorrow.") and (2) Droga do domu jest zajęta. ("The road to the house is blocked.") contain the same parts of speech in the same order, moreover in exactly the same grammatical form, in sentence (1) the phrase "do jutra" ("until tomorrow") modifies the verb "jest zajęta" ("is busy"), whereas in sentence (2) the phrase "do domu" ("to the house") is a dependent of the noun "droga" ("road"). Moreover, sometimes even native speakers can interpret the structure of a sentence in two different ways: the sentence Zauważyłem dziś samochód Adama, którego dawno nie widziałem. ("Today I noticed Adam's car, which/whom I had not seen for a long time.") can be interpreted in two ways, depending on what "którego" ("which"/"whom") refers to: to "samochodu Adama" ("Adam's car"), or perhaps to "Adama" ("Adam").

There are many different algorithms that solve the dependency parsing problem. Classical methods process a sentence word by word, from left to right, and insert edges based either on a fixed set of rules or on a machine learning algorithm. In this task we will use a different method. Your task will be to predict the dependency tree on the basis of word vectors obtained with the HerBERT model.

HerBERT is the Polish version of BERT, which is a language model and works as follows:

  1. BERT has a module called a tokenizer, which splits a sentence into certain subwords. For example, the sentence Dostaję klucz i biegnę do swojego pokoju. ("I get the key and run to my room.") is split into 'Dosta', 'ję', 'klucz', 'i', 'bieg', 'nę', 'do', 'swojego', 'pokoju', '.'. The tokenizer is equipped with a vocabulary that assigns unique numbers to subwords: in practice, therefore, we obtain the barely human-readable 18577, 2779, 22816, 1009, 4775, 2788, 2041, 5058, 7217, 1899.
  2. Next, BERT has a dictionary that converts these numbers into vectors of length 768. We therefore obtain a matrix of size 10 x 768.
  3. BERT has 12 layers, each of which takes the result of the previous one and applies a certain transformation to it. The details are not important in this task! What matters, however, is that the whole model is trained automatically, using large text corpora. Interpreting what each layer does is impossible! However, it may be that in the complicated algorithm that BERT has learnt, different layers play different roles.

Task

Your task will be the automatic syntactic parsing of sentences in Polish. We will skip a detailed explanation of how such trees are constructed; you can look at the examples yourself! You will receive a training dataset containing 1000 examples of sentence parses. The file train.conll contains labelled sentences, for example:

# Word - - - - Head - - -
1 Wyobraź _ _ _ _ 0 _ _ _
2 sobie _ _ _ _ 1 _ _ _
3 człowieka _ _ _ _ 1 _ _ _
4 znajdującego _ _ _ _ 3 _ _ _
5 się _ _ _ _ 4 _ _ _
6 na _ _ _ _ 4 _ _ _
7 ogromnej _ _ _ _ 8 _ _ _
8 górze _ _ _ _ 6 _ _ _
9 . _ _ _ _ 1 _ _ _

This is a way of encoding the following syntax tree of the complex sentence ("Imagine a person standing on a huge mountain."):

      Wyobraź                          
   ______|_____________                 
  |      |         człowieka           
  |      |             |                
  |      |        znajdującego         
  |      |      _______|__________      
  |      |     |                  na   
  |      |     |                  |     
  |      |     |                górze  
  |      |     |                  |     
sobie    .    się              ogromnej

We provide you with a Python function for loading the examples from this file and for visualising them. Your solution should:

  1. Split the sentence into subwords.
  2. Assign a vector to each subword. Here you must use the final or intermediate vectors computed by the HerBERT model.
  3. Aggregate the subword vectors so as to obtain word vectors.
  4. Implement and train a simple model that predicts the tree distances and tree depths of the individual words in the sentence.
  5. Use the distance and depth models to construct the syntax tree.

Constraints

  • Your final solution will be tested in an environment without a GPU.
  • Evaluating your solution (without training) on 200 test examples should take no longer than 5 minutes on Google Colab without a GPU.
  • You have at your disposal a BERT-type model: allegro/herbert-base-cased, and the tokenizer allegro/herbert-base-cased. You may not use any other pretrained models, or any datasets other than the one provided.
  • List of permitted libraries: transformers, nltk, torch.

Notes and hints

  • Numerous hints can be found in the templates of the functions you should implement.

Submission files

The solution to the task is a zip archive containing:

  1. This notebook
  2. The file with the weights of the distance model: distance_model.pth
  3. The file with the weights of the depth model: depth_model.pth

Running the whole notebook with the flag FINAL_EVALUATION_MODE set to False should create both weights files within at most 10 minutes.

Evaluation

During checking, the flag FINAL_EVALUATION_MODE will be set to True, and then the whole notebook will be run. The function parse_sentence that you implement, whose template you will find at the end of this notebook, will be evaluated on 200 test examples. The evaluation will be similar to the one implemented in the function evaluate_model. Remember, however, that the final evaluation function will additionally check whether the trees returned by your function parse_sentence are valid!

The evaluation may not take more than 3 minutes. You can run the validation of your solution on the provided validation dataset on Google Colab to find out whether you exceed the time limit. Using the script validation_script.py, you can make sure that your solution will be executed correctly on our grading servers:

python3 validation_script.py --train
python3 validation_script.py

When checking the task, we will use two metrics: UUAS and root placement.

  1. Root placement is the fraction of examples on which you correctly identify the root of the syntax tree,
  2. UUAS for a given sentence is the fraction of correctly placed edges. UUAS for a set is the mean of the results for the individual sentences.

For this task you can score between 0 and 2 points. Your score for this task will be computed with the function:

def points(root_placement, uuas):
    def scale(x, lower=0.5, upper=0.85):
        scaled = min(max(x, lower), upper)
        return (scaled - lower) / (upper - lower)
    return (scale(root_placement) + scale(uuas))

In other words, your score is the sum of the scores for root placement and UUAS. The score for a given metric is 0 if the value of that metric is below 0.5, and 1 if it is above 0.85. Between these values, the score increases linearly with the value of the metric.

Starter code

FINAL_EVALUATION_MODE = False  # While checking your solution, we will change this value to True
DEPTH_MODEL_PATH = 'depth_model.pth'  # Do not change!
DISTANCE_MODEL_PATH = 'distance_model.pth'  # Do not change!
from typing import List

import numpy as np
import torch
from torch.utils.data import DataLoader
from tqdm import tqdm
from transformers import (AutoModel, AutoTokenizer, PreTrainedModel,
                          PreTrainedTokenizer)
from utils import (ListDataset, ParsedSentence, Sentence, merge_subword_tokens,
                   read_conll, uuas_score)
tokenizer = AutoTokenizer.from_pretrained("allegro/herbert-base-cased")
model = AutoModel.from_pretrained("allegro/herbert-base-cased")
train_sentences = read_conll('train.conll')  # 1000 sentences
val_sentences = read_conll('valid.conll')  # 200 sentences

train_sentences[6].pretty_print()  # display the tree of one sentence
print(train_sentences[6])

Your solution

def get_distances(sentence: ParsedSentence):
    """Find the distances between every pair of words in the sentence.
    Returns a numpy matrix of shape (len(sentence), len(sentence))."""

    # TODO: implement me
    ...

    return distances

print(get_distances(train_sentences[1]))
def get_bert_embeddings(
    sentences_s: List[str],
    tokenizer: PreTrainedTokenizer, 
    model: PreTrainedModel,
    progress_bar: bool = False,
):
    """
    The function returns subword embeddings for a list of sentences.

    Arguments:
        sentences_s: List of sentences. Each sentence is represented as a string.
        tokenizer: HERBERT tokenizer
        model: HERBERT model
        progress_bar: Whether to display a progress bar.

    Returns:
        tokens: A list that, for each sentence, contains the list of subword tokens of that sentence.
        embeddings: A list that, for each sentence, contains a list of tensors of shape 
            (seq_len, emb_dim). Note that seq_len may differ between sentences.
    """

    # Hints:
    #  1. You can use the functions:
    #   encoded = tokenizer.batch_encode_plus(...)
    #   with torch.no_grad():
    #     model(**encoded, output_hidden_states=True)
    #  2. To speed up the computation, remember to group (batch) the sentences before passing them to the model.
    #  3. Remember that each sentence may have a different length, so HERBERT will apply padding to fill the extra
    #   space in the returned tensor. Remember to remove the padding from the results.
    #  4. The tokenizer and the model use special tokens (e.g. for the beginning and end of a sentence), which should also 
    #   be removed.

    # TODO: implement me
    ...

    return tokens, embeddings
def get_word_embeddings(sentences: List[Sentence], tokenizer, model):
    """The function returns word embeddings for a list of sentences, using the model and the tokenizer."""

    # Hints:
    #  1. Use the function get_bert_embeddings to obtain the subword embeddings.
    #  2. Use the function merge_subword_tokens to obtain the word embeddings.

    # TODO: implement me
    ...

    return embeddings
def get_datasets(sentences: List[ParsedSentence], tokenizer, model):
    embeddings = get_word_embeddings(sentences, tokenizer, model)
    distances = [get_distances(sent) for sent in sentences]
    depths = [dist[sent.root][..., None] for dist, sent in zip(distances, sentences)]
    dataset_dist = ListDataset(list(zip(embeddings, distances, sentences)))
    dataset_depth = ListDataset(list(zip(embeddings, depths, sentences)))
    return dataset_dist, dataset_depth


if not FINAL_EVALUATION_MODE:
    trainset_dist, trainset_depth =  get_datasets(train_sentences, tokenizer, model)
    valset_dist, valset_depth = get_datasets(val_sentences, tokenizer, model)
def pad_arrays(sequence, pad_with=np.inf):
    """
    Assumes that sequence contains arrays (ndarrays) with the same number of dimensions.
    Returns a tensor containing the data padded to the same dimensions with the value pad_with, 
    where the sequence index corresponds to the first dimension.
    """

    shapes = np.array([list(seq.shape) for seq in sequence])
    max_lens = list(shapes.max(axis=0))
    padded = [np.pad(
                seq, 
                tuple((0, max_lens[i] - seq.shape[i]) for i in range(seq.ndim)), 
                'constant', 
                constant_values=pad_with
            ) for seq in sequence]
    return torch.tensor(padded)


def collate_fn(batch):
    embeddings, targets, sentences = zip(*batch)
    padded_embeddings = pad_arrays(embeddings, pad_with=0)
    padded_targets = pad_arrays(targets, pad_with=np.inf)
    mask = padded_targets != torch.inf
    return padded_embeddings, padded_targets, mask, sentences


if not FINAL_EVALUATION_MODE:
    dist_trainloader = DataLoader(trainset_dist, batch_size=32, shuffle=True, collate_fn=collate_fn)
    dist_valloader = DataLoader(valset_dist, batch_size=32, shuffle=False, collate_fn=collate_fn)

    depth_trainloader = DataLoader(trainset_depth, batch_size=32, shuffle=True, collate_fn=collate_fn)
    depth_valloader = DataLoader(valset_depth, batch_size=32, shuffle=False, collate_fn=collate_fn)

# dist_trainloader and dist_valloader return tuples (embeddings, distances, masks, sentences)
# depths_trainloader and depths_valloader return tuples (embeddings, depths, masks, sentences)  
# embeddings.shape: (batch_size, max_seq_len, emb_dim)
# distances.shape: (batch_size, max_seq_len, max_seq_len)
# depths.shape: (batch_size, max_seq_len, 1)
class DistanceModel(torch.nn.Module):
    def __init__(self):
        # TODO: implement me
        ...

    def forward(self, x):
        # TODO: implement me
        ...


class DepthModel(torch.nn.Module):
    def __init__(self):
        # TODO: implement me
        ...

    def forward(self, x):
        # TODO: implement me
        ...
def loss_fn(output, target, mask):
    # mask, target and output are tensors of the same shape
    # mask contains 1 where target contains data, and 0 where there is padding

    # TODO: implement me
    ...


def train_model(model, dataloader, valloader, epochs, lr):
    """Training loop for your models."""
    # TODO: implement me
    ...


# During evaluation, the models should not be trained again.
if not FINAL_EVALUATION_MODE: 
    print("Training depth model")
    depth_model = DepthModel()
    # TODO: set the hyperparameters
    train_model(depth_model, depth_trainloader, depth_valloader, lr=..., epochs=...)  
    # save the model weights to a file
    torch.save(depth_model.state_dict(), DEPTH_MODEL_PATH)

    print("Training distance model")
    distance_model = DistanceModel()
    # TODO: set the hyperparameters
    train_model(distance_model, dist_trainloader, dist_valloader, lr=..., epochs=...)
    # save the model weights to a file
    torch.save(distance_model.state_dict(), DISTANCE_MODEL_PATH)
def parse_sentence(sent: Sentence, distance_model, depth_model, tokenizer, model) -> ParsedSentence:
    """Build the syntax tree for a single sentence.

    Arguments:
        sent: The sentence to parse.
        distance_model: Trained distance model
        depth_model: Trained depth model
        tokenizer: HERBERT tokenizer
        model: HERBERT model

    Returns:
        ParsedSentence: The sentence with the predicted syntax tree.

    """

    # Your solution should:
    # 1. Obtain the word embeddings for the sentence.
    # 2. Choose the root of the syntax tree heuristically, using depth_model.
    # 3. Compute the distances between every pair of nodes, using distance_model.
    # 4. Implement a heuristic method of your own design for choosing the edges 
    #    of the tree on the basis of the predicted distances.
    # 5. Obtain a ParsedSentence object. You can use the function ParsedSentence.from_edges_and_root
    # 6. Return the ParsedSentence object.

    # Hints:
    #  You can use sent.pretty_print() to visualise the parsed sentence.

    # Note:
    # This function will be used to score your solution. This function should return an actual tree,
    # with len(sent) - 1 edges. If your prediction is not a tree, it will be invalid and
    # you will not get any points for it. If you want to get only some of the points, by competing only in the 
    # root placement metric, you should still return a valid tree.

    # TODO: implement me
    ...

if not FINAL_EVALUATION_MODE:
    sent = train_sentences[30]
    parse_sentence(sent, distance_model, depth_model, tokenizer, model).pretty_print()  # Predicted tree
    sent.pretty_print()  # Gold tree (from the dataset)
    print(sent)

Evaluation

Code very similar to the code below will be used to evaluate the solution on the test sentences. By running the cells below, you can find out how many points your solution would score if we evaluated it on the validation data. Before submitting your solution, make sure that the whole notebook runs from start to finish without errors and without user intervention after executing the Run All command.

def points(root_placement, uuas):
    def scale(x, lower=0.5, upper=0.85):
        scaled = min(max(x, lower), upper)
        return (scaled - lower) / (upper - lower)
    return (scale(root_placement) + scale(uuas))

def evaluate_model(sentences: List[ParsedSentence], distance_model, depth_model, tokenizer, model):
    sum_uuas = 0
    root_correct = 0
    with torch.no_grad():
        for sent in sentences:
            parsed = parse_sentence(sent, distance_model, depth_model, tokenizer, model)
            root_correct += int(parsed.root == sent.root)
            sum_uuas += uuas_score(sent, parsed)
    
    root_placement = root_correct / len(sentences)
    uuas = sum_uuas / len(sentences)

    print(f"UUAS: {uuas * 100:.3}%")
    print(f"Root placement: {root_placement * 100:.3}%")
    print(f"Your score: {points(root_placement, uuas):.1}/2.0")
if not FINAL_EVALUATION_MODE:
    distance_model_loaded = DistanceModel()
    distance_model_loaded.load_state_dict(torch.load(DISTANCE_MODEL_PATH))

    depth_model_loaded = DepthModel()
    depth_model_loaded.load_state_dict(torch.load(DEPTH_MODEL_PATH))

    evaluate_model(val_sentences, distance_model_loaded, depth_model_loaded, tokenizer, model)

Translated by SOTA. The Polish original is the official version and wins wherever the two differ. The task parses Polish sentences, so the example sentences and trees stay in Polish, with English glosses in brackets. The notebook needs utils.py, train.conll and valid.conll from the original task folder. If you organise this olympiad and would like the translation removed, email [email protected] and we will take it down.

At a glance

You get
train.conll (1,000 sentences) and valid.conll (200 sentences) in the task folder; the HerBERT base cased model and tokenizer; utils.py.
You submit
A zip archive containing this notebook, distance_model.pth and depth_model.pth; parse_sentence() must return a valid tree for each sentence.
Scoring
Two metrics: root placement (fraction of sentences with the correct root) and UUAS (fraction of correctly placed edges per sentence, averaged over sentences). Each is mapped to [0, 1] as 0 below 0.5, 1 above 0.85 and linear in between; the task score is their sum (0–2 points). Invalid trees earn no credit.
Rules
  • Tested without a GPU; evaluation (without training) on 200 test sentences must take at most 5 minutes on Google Colab without a GPU (the Evaluation section also states that evaluation may not exceed 3 minutes).
  • Only allegro/herbert-base-cased (model and tokenizer) may be used as a pre-trained model; no datasets other than the one supplied.
  • Allowed libraries: transformers, nltk, torch.
  • Running the whole notebook with FINAL_EVALUATION_MODE = False must produce both weight files within 10 minutes.
Format
Stage I (online, solved at home), 22 April – 27 May 2024; notebook submitted through the Olympiad's submission website and scored automatically. Worth up to 2 points of the stage total of 10.

Details

Year
2024, Online
Round
Stage I · Task 3
Language
Polish; English translation by SOTA
License
Not stated by the source