epsilon-compositionality

Code for our paper Measuring Idiomaticity in Text Embedding Models with 𝜀-compositionality
git clone https://esimon.eu/repos/epsilon-compositionality.git
Log | Files | Refs | Submodules | README

commit f78433a84a8bf806b66fdb5a9d1122b8ef28bb82
parent 5c05b9ac51c42c9f654566d7d01778784d12959b
Author: Étienne Simon <esimon@esimon.eu>
Date:   Mon, 29 Sep 2025 06:13:28 +0200

rewrite everything

Diffstat:
MREADME.md | 14+++++++++++++-
Aepsilon_compositionality/__main__.py | 9+++++++++
Aepsilon_compositionality/build_dataset.py | 111+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aepsilon_compositionality/compute_statistics.py | 115+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aepsilon_compositionality/extract_embeddings.py | 136+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ashell.nix | 17+++++++++++++++++
6 files changed, 401 insertions(+), 1 deletion(-)

diff --git a/README.md b/README.md @@ -1 +1,13 @@ -# WIP: Compositionality: Definitions and its Relationship with Distributional Semantics +# Evaluating Idiomaticity in Sentence Embeddings with 𝜀-compositionality + +Reproduce everything: +```sh +python -m epsilon_compositionality +``` + +This is equivalent to: +```sh +python -m epsilon_compositionality.build_dataset +python -m epsilon_compositionality.extract_embeddings +python -m epsilon_compositionality.compute_statistics +``` diff --git a/epsilon_compositionality/__main__.py b/epsilon_compositionality/__main__.py @@ -0,0 +1,9 @@ +import epsilon_compositionality.build_dataset +import epsilon_compositionality.extract_embeddings +import epsilon_compositionality.compute_statistics + + +if __name__ == "__main__": + epsilon_compositionality.build_dataset.main() + epsilon_compositionality.extract_embeddings.main() + epsilon_compositionality.compute_statistics.main() diff --git a/epsilon_compositionality/build_dataset.py b/epsilon_compositionality/build_dataset.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python + +from typing import Any +import json +import pathlib + +import pandas as pd + + +INPUT_PATH = pathlib.Path(__file__).parent / "data" / "Finding-Idiomaticity-in-Word-Representations" / "dataset" +OUTPUT_PATH = pathlib.Path(__file__).parent / "data" / "ncimp.processed.jsonl" + + +def sample_filter(frame: pd.DataFrame) -> pd.DataFrame: + return frame[(frame["language"] == "en") & (frame["experiment_type"] == "Naturalistic")] + + +def extract_tagged(sentence: str, tag: str) -> str: + return " ".join(word for word, annotation in zip(sentence.split(), eval(tag)) if annotation) + + +def fix_data(compound: str, keep_modifier: True, keep_head: True, alternatives: dict[str, str]) -> dict[str, str]: + fixed: dict[str, str] = {} + + process = lambda x: " ".join(x.lower().split()[1 - keep_modifier:1 + keep_head]) + + uniques: set[str] = { process(compound), f"{process(compound)}s" } + for key, value in alternatives.items(): + if process(key) not in uniques and f"{process(key)}s" not in uniques: + uniques.add(process(key)) + uniques.add(f"{process(key)}s") + fixed[key] = value + + return fixed + + +def resolve_sentence(compound: str, input_sentence: pd.Series, support_annotations: dict[str, pd.DataFrame]) -> tuple[dict[str, Any], dict[str, Any]]: + filename: str = pathlib.Path(input_sentence["file"]).name + file_id: int = int(filename[len("naturalistics_examplesent"):-len(".csv")]) + support_annotation: pd.Series = support_annotations[filename].loc[compound] + + common: dict[str, Any] = { + "compound": compound, + "general class": input_sentence["ClassType"], + "general human compositionality": input_sentence["CompositionalityType"], + } + + sentence: dict[str, Any] = { + "compound": extract_tagged(support_annotation["original sentence"], support_annotation["original sentence_tag"]), + "modifier": support_annotation[f"modifier_{file_id}"], # acid + "head": support_annotation[f"head_{file_id}"], # test + "contextual class": input_sentence["ClassToken"], + "contextual human compositionality": input_sentence["CompositionalityToken"], + "original": support_annotation["original sentence"], # acid test + "modifier synonym": fix_data(compound, True, False, { # sour test + extract_tagged(support_annotation[key], support_annotation[f"{key}_tag"]): support_annotation[key] + for key in [ + f"synonym modifier_{file_id}", + f"modifier synonym alt1_{file_id}", + f"modifier synonym alt2_{file_id}", + f"modifier synonym alt3_{file_id}", + f"modifier synonym alt4_{file_id}", + ] + }), + "head synonym": fix_data(compound, False, True, { # acid analysis + extract_tagged(support_annotation[key], support_annotation[f"{key}_tag"]): support_annotation[key] + for key in [ + f"synonym head_{file_id}", + f"head synonym alt1_{file_id}", + f"head synonym alt2_{file_id}", + f"head synonym alt3_{file_id}", + f"head synonym alt4_{file_id}", + ] + }), + "both synonym": fix_data(compound, True, True, { # sour analysis + extract_tagged(support_annotation[key], support_annotation[f"{key}_tag"]): support_annotation[key] + for key in [ + f"synonym both", + f"both synonym alt1_{file_id}", + f"both synonym alt2_{file_id}", + f"both synonym alt3_{file_id}", + f"both synonym alt4_{file_id}", + ] + }), + } + + return common, sentence + + +def main() -> None: + input_annotations: pd.DataFrame = pd.read_excel(INPUT_PATH / "human_compositionality scores.xlsx") + input_annotations = sample_filter(input_annotations) + support_annotations: dict[str, pd.DataFrame] = { + path.name: pd.read_csv(path).set_index("compound") + for path in (INPUT_PATH / "EN").iterdir() + if path.name.startswith("naturalistics_examplesent") + } + + with OUTPUT_PATH.open("w") as output_file: + for compound, input_sample in input_annotations.groupby("compound"): + sentences: list[dict[str, Any]] = [] + sample: dict[str, Any] = {"sentences": sentences} + for input_sentence in input_sample.iloc: + sample_data, sentence = resolve_sentence(compound, input_sentence, support_annotations) + sentences.append(sentence) + sample.update(sample_data) + print(json.dumps(sample), file=output_file) + + +if __name__ == "__main__": + main() diff --git a/epsilon_compositionality/compute_statistics.py b/epsilon_compositionality/compute_statistics.py @@ -0,0 +1,115 @@ +from typing import Any +import collections +import itertools +import json +import pathlib + +import matplotlib.pyplot +import scipy.stats + + +INPUT_PATH = pathlib.Path(__file__).parent / "data" / "similarities" +CLASSES: list[str] = ["C", "PC", "NC"] + + +def get_epsilons(sentences: list[dict[str, Any]], method: str, parameter: float) -> tuple[float, float]: + epsilons: dict[str, list[float]] = {"positives": [], "negatives": []} + for sentence in sentences: + for polarity in ["positives", "negatives"]: + for alternative in sentence[polarity]: + if method == "multiplicative": + epsilons[polarity].append((alternative["contextual_similarity"] - parameter) / (alternative["phrase_similarity"] - parameter)) + elif method == "context": + epsilons[polarity].append(alternative["contextual_similarity"]) + assert len(epsilons["positives"]) == len(epsilons["negatives"]) + return epsilons["positives"], epsilons["negatives"] + + +def read_similarities(epsilon_method: str, epsilon_parameter: float) -> dict[str, Any]: + models: dict[str, Any] = {} + for model_path in INPUT_PATH.iterdir(): + models[model_path.stem] = { + subset: { + "head": {"positives": [], "negatives": []}, + "modifier": {"positives": [], "negatives": []}, + } for subset in CLASSES + } + + with model_path.open("r") as model_file: + for line in model_file: + compound = json.loads(line) + for side in ["head", "modifier"]: + positives, negatives = get_epsilons(compound[side], epsilon_method, epsilon_parameter) + models[model_path.stem][compound["comp_class"]][side]["positives"].extend(positives) + models[model_path.stem][compound["comp_class"]][side]["negatives"].extend(negatives) + return models + + +def plot_violins(models: dict[str, Any]) -> None: + fig, axs = matplotlib.pyplot.subplots(nrows=len(models)) + for model_index, (model_name, model_data) in enumerate(models.items()): + positives = [] + negatives = [] + labels = [] + for subset in CLASSES: + labels.append(subset) + positives.append([]) + negatives.append([]) + + for isubset, subset in enumerate(CLASSES): + for side in ["head", "modifier"]: + labels.append(f"{subset}.{side[0]}") + positives.append(model_data[subset][side]["positives"]) + negatives.append(model_data[subset][side]["negatives"]) + positives[isubset].extend(model_data[subset][side]["positives"]) + negatives[isubset].extend(model_data[subset][side]["negatives"]) + axs[model_index].violinplot(positives, side="low", showextrema=False, showmeans=True) + axs[model_index].violinplot(negatives, side="high", showextrema=False, showmeans=True) + axs[model_index].set_xticks(range(len(labels)+1), labels=[""]+labels) + axs[model_index].set_ylabel(model_name) + matplotlib.pyplot.show() + + +def print_table_cell(positives: list[float], negatives: list[float], method: str) -> None: + wilcoxon = scipy.stats.wilcoxon(positives, negatives, alternative="greater") + if method == "rank biserial": + n: int = len(positives) + rank_biserial = wilcoxon.statistic / (n * (n + 1) // 2) + print(f" {rank_biserial:<1.3f}", end="") + elif method == "p-value": + print(f" {wilcoxon.pvalue:<1.3f}", end="") + + +def print_table(models: dict[str, Any], method: str) -> None: + print(f"{'Model':>20}", end="") + for subset in CLASSES: + print(f" {subset:>5}", end="") + for subset in CLASSES: + for side in ["head", "modifier"]: + print(f" {subset:>3}.{side[0]}", end="") + print("") + + for model_name, model_data in models.items(): + print(f"{model_name:>20}", end ="") + for subset in CLASSES: + positives = list(itertools.chain(*(model_data[subset][side]["positives"] for side in ["head", "modifier"]))) + negatives = list(itertools.chain(*(model_data[subset][side]["negatives"] for side in ["head", "modifier"]))) + print_table_cell(positives, negatives, method) + + for subset in CLASSES: + for side in ["head", "modifier"]: + print_table_cell(model_data[subset][side]["positives"], model_data[subset][side]["negatives"], method) + print("") + + +def main() -> None: + models = read_similarities("multiplicative", 1) + print("P-VALUE") + print_table(models, "p-value") + print("\n\nRANK BISERIAL") + print_table(models, "rank biserial") + #plot_violins(models) + + +if __name__ == "__main__": + main() diff --git a/epsilon_compositionality/extract_embeddings.py b/epsilon_compositionality/extract_embeddings.py @@ -0,0 +1,136 @@ +from typing import Any, NamedTuple, Iterator +import copy +import json +import pathlib + +import torch +import tqdm + +from epsilon_compositionality.models import ModelFactory, Model + + +INPUT_PATH = pathlib.Path(__file__).parent / "data" / "ncimp.processed.jsonl" +OUTPUT_PATH = pathlib.Path(__file__).parent / "data" / "similarities" + + +def process_sample(input_sample: dict[str, Any]) -> dict[str, Any] | None: + """Process a NCIMP sample into set of (a, b, ac, bc). + + A processed sample has the following shape: + { + "compound": "acid test", + "comp_class": "NC", + "head": [ + { # sentence 1 + "positives": [ # compare "acid test" to alternatives + { + "phrase_a": "acid test", + "phrase_b": "acid trial", + "context_ac": "sentence1 with acid test…", + "context_bc": "sentence1 with acid trial…" + }, + { + "phrase_a": "acid test", + "phrase_b": "acid analysis", + "context_ac": "sentence1 with acid test…", + "context_bc": "sentence1 with acid analysis…" + }, + # … + ], + "negatives": [ # compare alternatives between themselves + { + "phrase_a": "acid analysis", + "phrase_b": "acid trial", + "context_ac": "sentence1 with acid analysis…", + "context_bc": "sentence1 with acid trial…" + }, + { + "phrase_a": "acid tryout", + "phrase_b": "acid analysis", + "context_ac": "sentence1 with acid tryout…", + "context_bc": "sentence1 with acid analysis…" + }, + ] + }, + # … more sentences + ], + "modifier": [ + # similarly set of sentences where acid is replaced instead of test (e.g. sour test) + ] + } + """ + output_sample: dict[str, Any] = { + "compound": input_sample["compound"], + "comp_class": input_sample["general class"], + } + for side_index, side in enumerate(["modifier", "head"]): + output_sample[side] = [] + for sentence in input_sample["sentences"]: + if len(sentence[f"{side} synonym"]) <= 1: + continue + + alternatives = list(sentence[f"{side} synonym"].items()) + second_alternatives = alternatives[1:] + [alternatives[0]] + if len(alternatives) == 2: + alternatives, second_alternatives = [alternatives[0]], [alternatives[1]] + + positives: list[Any] = [] + negatives: list[Any] = [] + for (alt_phrase, alt_sentence), (second_alt_phrase, second_alt_sentence) in zip(alternatives, second_alternatives): + positives.append({ + "phrase_a": input_sample["compound"].split()[side_index], + "phrase_b": alt_phrase.split()[side_index], + "context_ac": sentence["original"], + "context_bc": alt_sentence, + }) + negatives.append({ + "phrase_a": second_alt_phrase.split()[side_index], + "phrase_b": alt_phrase.split()[side_index], + "context_ac": second_alt_sentence, + "context_bc": alt_sentence, + }) + output_sample[side].append({"positives": positives, "negatives": negatives}) + if not output_sample[side]: + return None + return output_sample + + +def load_dataset() -> list[dict[str, Any]]: + dataset: list[dict[str, Any]] = [] + with INPUT_PATH.open("r") as input_file: + for line in input_file: + sample: dict[str, Any] | None = process_sample(json.loads(line)) + if sample: + dataset.append(sample) + return dataset + + +def main() -> None: + dataset: list[dict[str, Any]] = load_dataset() + OUTPUT_PATH.mkdir(parents=True, exist_ok=True) + for model_name in ModelFactory._factory: + model_path: pathlib.Path = OUTPUT_PATH / f"{model_name}.jsonl" + if "DeBERTa" in model_name: # FIXME random bug I don't want to investigate + continue + + model: Model = ModelFactory.get(model_name) + output: list[dict[str, Any]] = [] + + with model_path.open("w") as output_file: + for input_sample in tqdm.tqdm(dataset, f"Embedding dataset with {model_name}"): + output_sample: dict[str, Any] = copy.deepcopy(input_sample) + for side_index, side in enumerate(["modifier", "head"]): + for sentence in output_sample[side]: + for polarity in ["positives", "negatives"]: + for subsample in sentence[polarity]: + phrase_a: torch.Tensor = model.embed(subsample["phrase_a"]) + phrase_b: torch.Tensor = model.embed(subsample["phrase_b"]) + context_ac: torch.Tensor = model.embed(subsample["context_ac"]) + context_bc: torch.Tensor = model.embed(subsample["context_bc"]) + subsample["phrase_similarity"] = model.calculate_similarity(phrase_a, phrase_b) + subsample["contextual_similarity"] = model.calculate_similarity(context_ac, context_bc) + print(json.dumps(output_sample), file=output_file) + + +if __name__ == "__main__": + main() diff --git a/shell.nix b/shell.nix @@ -0,0 +1,17 @@ +{ pkgs ? import <nixpkgs> {} }: +pkgs.mkShellNoCC { + nativeBuildInputs = with pkgs.buildPackages; [ + (python3.withPackages (ps: with ps; [ + datasets + ipdb + matplotlib + openpyxl + pandas + pytorch + sentence-transformers + tiktoken + tqdm + transformers + ])) + ]; +}