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 d0edaaf471da72428199eaadd2fe4460feed0e0e
parent 91fb4c2e6710a54517cc5f58ebba5b7883eb3f51
Author: Étienne Simon <esimon@esimon.eu>
Date:   Mon,  6 Oct 2025 06:28:52 +0200

use distance epsilon and output latex

Diffstat:
MREADME.md | 6++++--
Mepsilon_compositionality/__main__.py | 4++--
Mepsilon_compositionality/compute_statistics.py | 141+++++++++++++++++++++++++++++++++++++++++++------------------------------------
Depsilon_compositionality/extract_embeddings.py | 136-------------------------------------------------------------------------------
Aepsilon_compositionality/extract_similarities.py | 134+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mshell.nix | 1-
6 files changed, 216 insertions(+), 206 deletions(-)

diff --git a/README.md b/README.md @@ -1,13 +1,15 @@ # Evaluating Idiomaticity in Sentence Embeddings with 𝜀-compositionality -Reproduce everything: +To reproduce everything: ```sh +git clone --recurse-submodules THIS_REPOSITORY python -m epsilon_compositionality ``` +The data is included as a git submodule, from there the python module `epsilon_compositionality` run all the necessary code and output a `results` directory containing the LaTeX code we included in the article. This is equivalent to: ```sh python -m epsilon_compositionality.build_dataset -python -m epsilon_compositionality.extract_embeddings +python -m epsilon_compositionality.extract_similarities python -m epsilon_compositionality.compute_statistics ``` diff --git a/epsilon_compositionality/__main__.py b/epsilon_compositionality/__main__.py @@ -1,9 +1,9 @@ import epsilon_compositionality.build_dataset -import epsilon_compositionality.extract_embeddings +import epsilon_compositionality.extract_similarities import epsilon_compositionality.compute_statistics if __name__ == "__main__": epsilon_compositionality.build_dataset.main() - epsilon_compositionality.extract_embeddings.main() + epsilon_compositionality.extract_similarities.main() epsilon_compositionality.compute_statistics.main() diff --git a/epsilon_compositionality/compute_statistics.py b/epsilon_compositionality/compute_statistics.py @@ -4,111 +4,122 @@ 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"] +RESULTS_PATH = pathlib.Path() / "results" +CLASSES_ORDER: list[str] = ["C", "PC", "NC"] +MODELS_ORDER: list[str] = ["BERT", "ModernBERT", "DeBERTa", "S-BERT", "S-GPT", "E5", "UAE", "QWEN"] +MODELS_HAS_SUB: set[str] = set(["BERT", "ModernBERT", "DeBERTa"]) +MODELS_SUB_ORDER: list[str] = ["cls", "clssep", "mean"] -def get_epsilons(sentences: list[dict[str, Any]], method: str, parameter: float) -> tuple[float, float]: + +def get_epsilons(sentences: list[dict[str, Any]], method: str, offset: int = 0) -> 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)) + epsilons[polarity].append((1 - alternative["contextual_similarity"] + offset) / (1 - alternative["phrase_similarity"] + offset) - 1) elif method == "context": - epsilons[polarity].append(alternative["contextual_similarity"]) + epsilons[polarity].append(1 - alternative["contextual_similarity"]) + elif method == "pairs": + epsilons[polarity].append((1 - alternative["contextual_similarity"], 1 - alternative["phrase_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]: +def read_similarities(epsilon_method: str, offset: int = 0) -> 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 + } for subset in CLASSES_ORDER } 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) + positives, negatives = get_epsilons(compound[side], epsilon_method, offset) 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: +def table_cell(positives: list[float], negatives: list[float], method: str) -> str: 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="") + rank_biserial = 100 * wilcoxon.statistic / (n * (n + 1) // 2) + return f"{rank_biserial:<4.1f}" 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("") + return f"{wilcoxon.pvalue:<1.3f}" + + +def latex_main_statistics_table(output_path: pathlib.Path, models: dict[str, Any]) -> None: + with output_path.open("w") as output: + for model_name in MODELS_ORDER: + for isubmodel, submodel in enumerate(MODELS_SUB_ORDER if model_name in MODELS_HAS_SUB else [""]): + print(f"{model_name if isubmodel==0 else '':<10}", end=" & ", file=output) + print(f"{submodel:<6}", end=" & ", file=output) + model_key: str = f"{model_name}-{submodel}" if submodel else model_name + for imethod, method in enumerate(["p-value", "rank biserial"]): + for isubset, subset in enumerate(CLASSES_ORDER): + positives = list(itertools.chain(*(models[model_key][subset][side]["positives"] for side in ["head", "modifier"]))) + negatives = list(itertools.chain(*(models[model_key][subset][side]["negatives"] for side in ["head", "modifier"]))) + print(table_cell(positives, negatives, method), end=" & " if isubset<len(CLASSES_ORDER)-1 or imethod==0 else " \\\\\n", file=output) + + +def get_mu_sigma(data: list[float]) -> tuple[float, float]: + mu = sum(data) / len(data) + sigma = (sum((x - mu)**2 for x in data) / (len(data) - 1))**0.5 + return mu, sigma + + +def latex_distances_statistics(output_path: pathlib.Path) -> None: + models = read_similarities("pairs") + with output_path.open("w") as output: + for model_name in MODELS_ORDER: + for isubmodel, submodel in enumerate(MODELS_SUB_ORDER if model_name in MODELS_HAS_SUB else [""]): + print(f"{model_name if isubmodel==0 else '':<10}", end=" & ", file=output) + print(f"{submodel:<6}", end=" & ", file=output) + model_key: str = f"{model_name}-{submodel}" if submodel else model_name + contextual: list[float] = [] + phrasal: list[float] = [] + for subset in CLASSES_ORDER: + for side in ["head", "modifier"]: + for polarity in ["positives", "negatives"]: + contextual.extend(x for x, _ in models[model_key][subset][side][polarity]) + phrasal.extend(x for _, x in models[model_key][subset][side][polarity]) + pmu, psigma = get_mu_sigma(phrasal) + cmu, csigma = get_mu_sigma(contextual) + print(f"{pmu:5.3f} & {psigma:5.3f} & {cmu:5.3f} & {csigma:5.3f} \\\\", file=output) + + +def write_violins(output_path: pathlib.Path) -> None: + models = read_similarities("multiplicative", 0.05) + with output_path.open("w") as output: + for model, data in models.items(): + for polarity in ["positives", "negatives"]: + print(f"{model}\x1e{polarity}", end="", file=output) + for subset in CLASSES_ORDER: + for side in ["head", "modifier"]: + for sample in models[model][subset][side][polarity]: + print(f"\t{sample}", end="", file=output) + print("", file=output) 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) + models = read_similarities("multiplicative") + RESULTS_PATH.mkdir(exist_ok=True) + latex_main_statistics_table(RESULTS_PATH / "main statistics.tex", models) + latex_distances_statistics(RESULTS_PATH / "distances statistics.tex") + write_violins(RESULTS_PATH / "violins.dat") if __name__ == "__main__": diff --git a/epsilon_compositionality/extract_embeddings.py b/epsilon_compositionality/extract_embeddings.py @@ -1,136 +0,0 @@ -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/epsilon_compositionality/extract_similarities.py b/epsilon_compositionality/extract_similarities.py @@ -0,0 +1,134 @@ +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" + + 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 @@ -4,7 +4,6 @@ pkgs.mkShellNoCC { (python3.withPackages (ps: with ps; [ datasets ipdb - matplotlib openpyxl pandas pytorch