Lors d'une migration GED, certaines rubriques changent de nom, se divisent, fusionnent ou n'ont pas d'équivalent. Un appariement un-à-un forcé crée des correspondances trompeuses et déplace parfois des pièces vers une catégorie inadéquate.
Une matrice explicable propose des liens multiples avec coûts, masses et avertissements, puis exporte un fichier de revue. Aucun skos:exactMatch ni déplacement de document n'est créé automatiquement.
Télécharger le projet complet, révision r001
Un corpus de correspondances multiples
Vingt-quatre paires de plans ont chacune8 concepts source et10 cibles, soit 192et240 concepts. Douze domaines servent au calibrage, douze autres au test. Les domaines et enveloppes textuelles sont distincts, mais les prototypes sémantiques de relation et synonymes sont partagés : ce n’est pas une expérience totalement hors distribution. Ce choix rend les mécanismes lisibles ; les performances ne prouvent pas une généralisation métier.
Chaque paire possède64 notes originales de charge. Leurs deux affectations de classement sont simulées indépendamment et produisent a et b par comptes/64 ; ce ne sont pas des annotations de correspondance. Tous les concepts du parcours principal ont une masse positive. Le gold contient des relations un-à-plusieurs, plusieurs-à-un et une rubrique sans équivalent. Il reste dans un fichier séparé, jamais dans les coûts.
Les broader restent internes à un plan ; le code rejette doublons, parents absents et cycles. Ce TP adopte cette convention acyclique locale ; il ne prétend pas définir toutes les contraintes générales de SKOS. Aucun export ne déclare skos:exactMatch et aucune migration n'est appliquée.
E5 : sens et contexte
E5-small gelé à614241f622f53c4eeff9890bdc4f31cfecc418b3 encode libellé+définition et une seconde chaîne de contexte d’ancêtres. Le préfixe query: est symétrique pour source et cible. Pooling masqué, tokens spéciaux réels inclus, puis normalisationL2 en 384 dimensions ; le code refuse plus de 512 tokens et vérifie tous les fichiers et tenseurs chargés.
Une racine utilise la même phrase explicite de contexte de racine, donc racine/racine a un contexte identique. Le coût est Cij=1−cos(ei,ej)+0,25(1−cos(ai,aj)). Le facteur0,25 est figé. Les cosinus calculés avec les vecteurs FP32 sont accumulés en FP64 puis bornés à[-1,1] pour l’arrondi. Les coûts ne connaissent pas le gold.
Trois méthodes, trois interprétations
Le top 1 cosinus prend la cible la plus proche textuellement, avec seuil choisi au calibrage. Il ignore les masses, donc reste inchangé dans l’exercice de masses. Le transport équilibré impose ses marges a et b : il peut déplacer une charge vers une cible sémantiquement médiocre pour satisfaire ces contraintes.
Le transport non équilibré minimise somme(ΠC)+epsilon somme(Π(logΠ−1))+rho_a KL(Π1||a)+rho_b KL(Πᵀ1||b). KL généralisée vaut somme(p log(p/q)−p+q), avec zéro traité explicitement. Les pénalités de marge autorisent déficit et excès ; une marge peut dépasser sa charge initiale. Aucune de ces quantités n’est une probabilité de correspondance.
Le noyau logarithmique vaut −C/epsilon. On alterne logu=fa(loga−logsumexp(logK+logv)) et logv=fb(logb−logsumexp(logKᵀ+logu)), avec fa=rho_a/(rho_a+epsilon). Rho infini donne fa=1 pour le cas équilibré. La stabilisation soustrait le maximum dans logsumexp. Les masses nulles sont retirées du sous-problème puis réinsérées sous forme de lignes/colonnes nulles. Des totaux tous nuls, données non finies ou paramètres invalides sont refusés ; une absence de convergence est une erreur lisible.
La convergence exige variation maximale des potentiels logarithmiques au plus 1e-11, sous20000 itérations, et des marges équilibrées à1e-8. Le petit cas2×2 est comparé à POT 0.9.7 en FP64 avec reg_type='entropy' explicite, tolérance de plan1e-8. Le défaut kl utilise une autre référence de régularisation. Les deux plans sont évalués avec notre même formule d’objectif ; un total_cost rapporté par POT n’est pas assimilé aveuglément à cette convention de constante.
Calibrer avant le test
Epsilon parcourt0,03et0,1 ; rho0,1,1et10. Les seuils de lien portent sur Πij/ai, et un seuil de revue sur somme_jΠij/ai. Ces ratios peuvent dépasser1. Ils ne sont ni normalisés en probabilités ni nommés confiance. La compatibilité business_scope filtre les propositions financières incompatibles en EXCLUDED ; le solveur lui-même utilise les coûts finis et cette règle n’est pas une contrainte cachée de transport.
Les grilles et leur ordre sont figés. Sur les 12 paires de calibrage, chaque méthode choisit le micro-F1 des liens multiples, puis le premier élément de grille en cas d’égalité. Les 12 paires test ne choisissent aucun seuil, epsilon ou rho. Les plans test peuvent être calculés sans gold ; leurs métriques ne sont calculées qu’après calibration.
Les propositions sont comparées à l’ensemble gold des liens : TP/FP/FN, précision, rappel, F1, couverture des concepts source, faux liens sur la rubrique sans équivalent, REVIEW et EXCLUDED. Les sorties indiquent marges, déficits, excès, objectif et résidu du solveur. La durée du solveur CPU est séparée de l’encodage GPU. Un score meilleur sur ces petites taxonomies ne justifie pas une migration automatique.
Exercice à embeddings figés
La paire test 12 est choisie avant les résultats. Multiplier la masse de Visites atelier par dix puis renormaliser a, sans changer b, les embeddings ou les paramètres choisis. Recalculer équilibré et non équilibré. Le top 1 reste textuel ; les plans peuvent changer. Lire les valeurs observées sans exiger de gain, puis comparer les définitions avant toute décision humaine de migration. Les sorties principales restent protégées par hash.
Étape 01 — Charger les plans comme des concepts identifiés
SKOS, libellés et hiérarchie
Erreur : Les concepts sont fusionnés sur leur seul label
Correction : Conserver URI et plan d'origine ; refuser les cycles dans le minimum.
Fichier src/ged_migration_taxonomies/data.py
import hashlib, json, random
from .config import save_json
DOMAINS = [
"water pumping",
"railway switches",
"solar panels",
"industrial cooling",
"harbour cranes",
"food conveyors",
"hospital ventilation",
"urban elevators",
"hydraulic presses",
"optical sensors",
"textile looms",
"district heating",
"wind turbines",
"laboratory freezers",
"mining drills",
"airport lighting",
"marine generators",
"paper rollers",
"waste sorting",
"telecom antennas",
"irrigation gates",
"battery storage",
"ceramic furnaces",
"robotic welders",
]
SOURCE = [
(
"Visites atelier",
"Scheduled routine and exceptional site visits for equipment maintenance.",
),
("Inspections", "Safety inspections and conformity observations."),
(
"Étalonnage",
"Calibration of measurement instruments against reference standards.",
),
("Remises en état", "Repair of defective components and restoration of service."),
("Commandes neuves", "Purchase orders for new equipment from external suppliers."),
("Renouvellements", "Replacement equipment procurement from external suppliers."),
(
"Ancien registre papier",
"Retired paper visitor sign-in book; no maintenance content.",
),
(
"Comptes rendus",
"Technical reports covering completed jobs and incident investigations.",
),
]
TARGET = [
("Tournées programmées", "Routine scheduled equipment maintenance visits."),
("Déplacements urgents", "Exceptional unscheduled equipment maintenance visits."),
("Contrôles sécurité", "Safety inspection records and conformity observations."),
("Réglage métrologique", "Instrument calibration against reference standards."),
("Réparations", "Restoration of defective components to working service."),
(
"Approvisionnements",
"Purchase and replacement equipment procurement from suppliers.",
),
("Rapports de travaux", "Technical reports about completed maintenance work."),
("Analyses incidents", "Technical investigation reports concerning incidents."),
("Archives financières", "Historical financial ledger and payment records."),
("Innovation", "Proposals for new engineering research projects."),
]
LINKS = [[0, 1], [2], [3], [4], [5], [5], [], [6, 7]]
def validate_broader(concepts):
ids = {c["id"] for c in concepts}
if len(ids) != len(concepts):
raise ValueError("Duplicate concept ID")
byid = {c["id"]: c for c in concepts}
for c in concepts:
seen = {c["id"]}
parent = c["broader"]
while parent is not None:
if parent not in byid or parent in seen:
raise ValueError("Invalid or cyclic broader relation")
seen.add(parent)
parent = byid[parent]["broader"]
def ancestors(concept, concepts):
validate_broader(concepts)
byid = {c["id"]: c for c in concepts}
values = []
parent = concept["broader"]
while parent is not None:
c = byid[parent]
values.append(c["label"] + ". " + c["definition"])
parent = c["broader"]
return (
" Ancestor: ".join(values)
if values
else "Root concept of this independent classification scheme."
)
def prepare(config):
pairs = []
gold = []
pieces = []
templates = {}
for family, domain in enumerate(DOMAINS):
split = "calibration" if family < 12 else "test"
template = (
f"Independent {domain} vocabulary revision {family+1}."
if split == "calibration"
else f"The {domain} documentation office defines scope {family+1} below."
)
templates[str(family)] = {
"id": f"template-{family:02d}",
"split": split,
"text": template,
}
source = []
target = []
for side, specs, parents in [
("s", SOURCE, [None, 0, 0, 0, None, 4, None, 0]),
("t", TARGET, [None, 0, 0, 2, 0, None, 0, 6, None, None]),
]:
values = []
for i, (label, definition) in enumerate(specs):
values.append(
{
"id": f"P{family:02d}-{side}{i}",
"label": label,
"definition": f"{template} {definition} Domain: {domain}.",
"broader": (
None
if parents[i] is None
else f"P{family:02d}-{side}{parents[i]}"
),
"business_scope": (
"financial" if side == "t" and i == 8 else "technical"
),
"active": True,
}
)
validate_broader(values)
if side == "s":
source = values
else:
target = values
rng = random.Random(17000 + family)
src = list(range(8)) + [rng.randrange(8) for _ in range(56)]
tgt = list(range(10)) + [rng.randrange(10) for _ in range(54)]
rng.shuffle(src)
rng.shuffle(tgt)
for j, (a, b) in enumerate(zip(src, tgt)):
body = f"Inventory note {family:02d}-{j:02d} about {domain}. Independently recorded source and destination filing loads, not an alignment annotation."
pieces.append(
{
"piece_id": f"piece-{family:02d}-{j:02d}",
"pair": family,
"text": body,
"text_sha256": hashlib.sha256(body.encode()).hexdigest(),
"source_bucket": source[a]["id"],
"target_bucket": target[b]["id"],
}
)
a = [src.count(i) / 64 for i in range(8)]
b = [tgt.count(i) / 64 for i in range(10)]
pair = {
"pair": family,
"split": split,
"domain": domain,
"template_id": f"template-{family:02d}",
"source": source,
"target": target,
"a": a,
"b": b,
"mass_source": "64 independently simulated filing-load notes; not gold link counts",
}
pairs.append(pair)
gold.append(
{
"pair": family,
"split": split,
"links": [
[source[i]["id"], target[j]["id"]]
for i, links in enumerate(LINKS)
for j in links
],
"without_equivalent": [source[6]["id"]],
}
)
save_json("data/generated/input/pages.json", pairs)
save_json("data/generated/input/pieces.json", pieces)
save_json("data/generated/input/templates.json", templates)
save_json("data/generated/labels/gold.json", gold)
save_json(
"data/generated/labels/validation.json",
[g for g in gold if g["split"] == "calibration"],
)
save_json(
"data/generated/manifest.json",
{
"pairs": 24,
"source_concepts": 192,
"target_concepts": 240,
"pieces": 1536,
"calibration_pairs": 12,
"test_pairs": 12,
"model_features_have_gold": False,
"shared_semantic_relation_pattern": True,
"domain_and_template_disjoint": True,
"limits": "Concept relation pattern and synonym prototypes shared across pairs; distinct domains/templates do not make the task fully out-of-distribution.",
},
)
Étape 02 — Construire une première correspondance textuelle
Embeddings et coût symétrique
Erreur : Les correspondances gold servent au contexte d'ancêtres
Correction : Construire le contexte depuis broader seulement, sans accès au mapping gold.
Fichier src/ged_migration_taxonomies/features.py
import numpy as np
from .encoder import Encoder
from .data import ancestors
from .config import read_json, save_json
def baseline(config):
pairs = read_json("data/generated/input/pages.json")
encoder = Encoder(config)
rows = []
for pair in pairs:
concepts = pair["source"] + pair["target"]
texts = [c["label"] + ". " + c["definition"] for c in concepts]
contexts = [
ancestors(c, pair["source"] if i < 8 else pair["target"])
for i, c in enumerate(concepts)
]
values = encoder.encode_queries(texts).astype(np.float64)
context = encoder.encode_queries(contexts).astype(np.float64)
cosine = np.clip(values[:8] @ values[8:].T, -1, 1)
ancestor_cosine = np.clip(context[:8] @ context[8:].T, -1, 1)
cost = 1 - cosine + config["ancestor_weight"] * (1 - ancestor_cosine)
rows.append(
{
"pair": pair["pair"],
"concept_ids": [c["id"] for c in concepts],
"texts": texts,
"contexts": contexts,
"embeddings": values.tolist(),
"ancestor_embeddings": context.tolist(),
"cosine": cosine.tolist(),
"ancestor_cosine": ancestor_cosine.tolist(),
"cost": cost.tolist(),
"top1": [int(np.argmax(row)) for row in cosine],
}
)
save_json(
"outputs/features.json",
{
"rows": rows,
"encoder": encoder.identity,
"encoding_calls": encoder.calls,
"labels_visible": False,
"ancestor_weight": config["ancestor_weight"],
},
)
Étape 03 — Déduire les mises à l'échelle du transport
Entropie, KL des marges et log-space
Erreur : Comparer reg_type='kl' au calcul entropique du TP
Correction : Passer reg_type='entropy' dans la référence et expliquer cette différence de convention.
Fichier src/ged_migration_taxonomies/transport.py
"""Entropy-regularized transport in float64 log space; explicit mass semantics."""
import math, time, warnings
import numpy as np
def logsumexp(values, axis):
maximum = np.max(values, axis=axis, keepdims=True)
return (
maximum + np.log(np.exp(values - maximum).sum(axis=axis, keepdims=True))
).squeeze(axis)
def generalized_kl(p, q):
p = np.asarray(p, dtype=np.float64)
q = np.asarray(q, dtype=np.float64)
if (
np.any(p < 0)
or np.any(q < 0)
or not np.isfinite(p).all()
or not np.isfinite(q).all()
):
raise ValueError("Invalid KL masses")
if np.any((q == 0) & (p > 0)):
return math.inf
active = p > 0
return float(
np.sum(p[active] * np.log(p[active] / q[active])) - np.sum(p) + np.sum(q)
)
def objective(plan, cost, a, b, epsilon, rho_a, rho_b):
positive = plan > 0
entropy = float(np.sum(plan[positive] * (np.log(plan[positive]) - 1)))
linear = float(np.sum(plan * cost))
left = generalized_kl(plan.sum(1), a)
right = generalized_kl(plan.sum(0), b)
value = linear + epsilon * entropy
if math.isfinite(rho_a):
value += rho_a * left
if math.isfinite(rho_b):
value += rho_b * right
return {
"linear": linear,
"negative_entropy": entropy,
"row_KL": left,
"column_KL": right,
"regularized": value,
"convention": "P*(logP-1); generalized KL includes -p+q",
}
def sinkhorn_unbalanced(
a, b, cost, epsilon, rho_a, rho_b=None, tolerance=1e-11, max_iterations=20000
):
started = time.perf_counter()
a = np.asarray(a, dtype=np.float64)
b = np.asarray(b, dtype=np.float64)
cost = np.asarray(cost, dtype=np.float64)
rho_b = rho_a if rho_b is None else rho_b
if a.ndim != 1 or b.ndim != 1 or cost.shape != (len(a), len(b)):
raise ValueError("Mass/cost dimensions")
if (
not np.isfinite(a).all()
or not np.isfinite(b).all()
or not np.isfinite(cost).all()
or np.any(a < 0)
or np.any(b < 0)
or a.sum() <= 0
or b.sum() <= 0
):
raise ValueError("Finite nonnegative masses with positive total required")
if (
not math.isfinite(epsilon)
or epsilon <= 0
or any(math.isnan(r) or r <= 0 for r in [rho_a, rho_b])
or tolerance <= 0
or max_iterations < 1
):
raise ValueError("Invalid regularization/convergence configuration")
balanced = math.isinf(rho_a) and math.isinf(rho_b)
if balanced and abs(a.sum() - b.sum()) > 1e-12:
raise ValueError("Balanced totals differ")
active_a = a > 0
active_b = b > 0
aa = a[active_a]
bb = b[active_b]
C = cost[np.ix_(active_a, active_b)]
kernel = -C / epsilon
lu = np.zeros(len(aa))
lv = np.zeros(len(bb))
fa = 1.0 if math.isinf(rho_a) else rho_a / (rho_a + epsilon)
fb = 1.0 if math.isinf(rho_b) else rho_b / (rho_b + epsilon)
residual = math.inf
for iteration in range(1, max_iterations + 1):
new_u = fa * (np.log(aa) - logsumexp(kernel + lv[None, :], axis=1))
new_v = fb * (np.log(bb) - logsumexp(kernel + new_u[:, None], axis=0))
residual = max(
float(np.max(np.abs(new_u - lu))), float(np.max(np.abs(new_v - lv)))
)
lu, lv = new_u, new_v
if not np.isfinite(lu).all() or not np.isfinite(lv).all():
raise FloatingPointError("Non-finite Sinkhorn dual iterate")
if residual <= tolerance:
break
else:
raise RuntimeError(
f"Sinkhorn did not converge after {max_iterations} iterations; residual={residual}"
)
small = np.exp(lu[:, None] + kernel + lv[None, :])
plan = np.zeros_like(cost)
plan[np.ix_(active_a, active_b)] = small
if not np.isfinite(plan).all():
raise FloatingPointError("Non-finite transport plan")
row = plan.sum(1)
col = plan.sum(0)
if balanced and max(np.max(np.abs(row - a)), np.max(np.abs(col - b))) > 1e-8:
raise RuntimeError("Balanced marginal residual too large")
return {
"plan": plan.tolist(),
"row_mass": row.tolist(),
"column_mass": col.tolist(),
"row_deficit": np.maximum(a - row, 0).tolist(),
"row_excess": np.maximum(row - a, 0).tolist(),
"column_deficit": np.maximum(b - col, 0).tolist(),
"column_excess": np.maximum(col - b, 0).tolist(),
"objective": objective(plan, cost, a, b, epsilon, rho_a, rho_b),
"iterations": iteration,
"residual": residual,
"seconds": time.perf_counter() - started,
"active_rows": np.flatnonzero(active_a).tolist(),
"active_columns": np.flatnonzero(active_b).tolist(),
"balanced": balanced,
"epsilon": epsilon,
"rho_a": None if math.isinf(rho_a) else rho_a,
"rho_b": None if math.isinf(rho_b) else rho_b,
}
def compare_pot(a, b, cost, epsilon, rho_a, rho_b=None, tolerance=1e-8):
import ot
rho_b = rho_a if rho_b is None else rho_b
a = np.asarray(a, dtype=np.float64)
b = np.asarray(b, dtype=np.float64)
cost = np.asarray(cost, dtype=np.float64)
ours = sinkhorn_unbalanced(a, b, cost, epsilon, rho_a, rho_b)
ia = a > 0
ib = b > 0
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
small, info = ot.unbalanced.sinkhorn_unbalanced(
a[ia],
b[ib],
cost[np.ix_(ia, ib)],
epsilon,
(rho_a, rho_b),
reg_type="entropy",
numItermax=20000,
stopThr=1e-13,
log=True,
)
reference = np.zeros_like(cost)
reference[np.ix_(ia, ib)] = small
error = float(np.max(np.abs(reference - np.asarray(ours["plan"]))))
if error > tolerance:
raise AssertionError(f"POT plan discrepancy {error} > {tolerance}")
return {
"ours": ours,
"reference_plan": reference.tolist(),
"max_plan_error": error,
"tolerance": tolerance,
"reg_type": "entropy",
"pot_version": ot.__version__,
"pot_reported_total_cost": float(info["total_cost"]),
"reference_objective_recomputed": objective(
reference, cost, a, b, epsilon, rho_a, rho_b
),
"warnings": [str(w.message) for w in caught],
}
Étape 04 — Résoudre des plans avec fusions et divisions
Relaxation des marges et revue
Erreur : Toutes les lignes sont normalisées à 1 puis appelées confiance
Correction : Conserver masses brutes, marges, coûts et règles de revue calibrées.
Fichier src/ged_migration_taxonomies/run.py
import itertools, math
import numpy as np
from .config import read_json, save_json, sha
from .transport import sinkhorn_unbalanced, compare_pot
from .proposals import propose, metrics
def inspect_mechanism(config):
a = [0.7, 0.3]
b = [0.4, 0.6]
cost = [[0.1, 0.9], [0.8, 0.2]]
reference = compare_pot(a, b, cost, 0.1, 1.0, 0.7)
permutation = sinkhorn_unbalanced(
a[::-1], b[::-1], np.asarray(cost)[::-1, ::-1], 0.1, 1.0, 0.7
)
if (
np.max(
np.abs(
np.asarray(reference["ours"]["plan"])
- np.asarray(permutation["plan"])[::-1, ::-1]
)
)
> 1e-10
):
raise AssertionError("Permutation failure")
zero = sinkhorn_unbalanced([1.0, 0.0], [0.4, 0.6], cost, 0.1, 1.0)
save_json(
"outputs/mechanism.json",
{
"a": a,
"b": b,
"cost": cost,
"reference": reference,
"permutation": permutation,
"zero_mass": zero,
"status": "PASS",
},
)
def compute_all(config):
pairs = read_json("data/generated/input/pages.json")
features = {r["pair"]: r for r in read_json("outputs/features.json")["rows"]}
solutions = []
for pair in pairs:
for eps in config["epsilon_grid"]:
solutions.append(
{
"pair": pair["pair"],
"variant": "balanced",
"epsilon": eps,
"rho": None,
"solution": sinkhorn_unbalanced(
pair["a"],
pair["b"],
features[pair["pair"]]["cost"],
eps,
math.inf,
),
}
)
for rho in config["rho_grid"]:
solutions.append(
{
"pair": pair["pair"],
"variant": "unbalanced",
"epsilon": eps,
"rho": rho,
"solution": sinkhorn_unbalanced(
pair["a"],
pair["b"],
features[pair["pair"]]["cost"],
eps,
rho,
tolerance=config["solver_tolerance"],
max_iterations=config["max_iterations"],
),
}
)
save_json("outputs/plans.json", solutions)
def calibrate(config):
pairs = read_json("data/generated/input/pages.json")
features = {r["pair"]: r for r in read_json("outputs/features.json")["rows"]}
plans = read_json("outputs/plans.json")
gold = {g["pair"]: g for g in read_json("data/generated/labels/validation.json")}
if set(gold) != set(range(12)):
raise ValueError("Calibration gold only required")
lookup = {
(r["pair"], r["variant"], r["epsilon"], r["rho"]): r["solution"] for r in plans
}
choices = {}
tables = {}
for variant in ["top1", "balanced", "unbalanced"]:
options = (
[(None, None, None, None, t) for t in config["cosine_threshold_grid"]]
if variant == "top1"
else [
(e, r, l, m, None)
for e in config["epsilon_grid"]
for r in ([None] if variant == "balanced" else config["rho_grid"])
for l in config["link_mass_ratio_grid"]
for m in config["minimum_row_mass_ratio_grid"]
]
)
rows = []
for eps, rho, link, row, cos in options:
total = {"tp": 0, "fp": 0, "fn": 0}
for pair in pairs[:12]:
solution = (
None
if variant == "top1"
else lookup[pair["pair"], variant, eps, rho]
)
result = metrics(
propose(
pair, features[pair["pair"]], variant, solution, link, row, cos
),
gold[pair["pair"]],
)
for k in total:
total[k] += result[k]
score = (
2 * total["tp"] / (2 * total["tp"] + total["fp"] + total["fn"])
if 2 * total["tp"] + total["fp"] + total["fn"]
else 0.0
)
rows.append(
{
"epsilon": eps,
"rho": rho,
"link_ratio": link,
"row_ratio": row,
"cosine_threshold": cos,
"counts": total,
"micro_F1": score,
}
)
# Stable predefined grid order breaks ties, independent of test labels.
winner = max(range(len(rows)), key=lambda i: (rows[i]["micro_F1"], -i))
choices[variant] = dict(rows[winner], grid_index=winner)
tables[variant] = rows
save_json(
"outputs/calibration.json",
{
"pairs": list(range(12)),
"choices": choices,
"grid": tables,
"tie": "first in predefined grid",
"test_gold_visible": False,
},
)
def evaluate(config):
pairs = read_json("data/generated/input/pages.json")
features = {r["pair"]: r for r in read_json("outputs/features.json")["rows"]}
plans = read_json("outputs/plans.json")
gold = {g["pair"]: g for g in read_json("data/generated/labels/gold.json")}
selected = read_json("outputs/calibration.json")["choices"]
lookup = {
(r["pair"], r["variant"], r["epsilon"], r["rho"]): r["solution"] for r in plans
}
rows = []
for pair in pairs:
for variant, choice in selected.items():
solution = (
None
if variant == "top1"
else lookup[pair["pair"], variant, choice["epsilon"], choice["rho"]]
)
proposals = propose(
pair,
features[pair["pair"]],
variant,
solution,
choice["link_ratio"],
choice["row_ratio"],
choice["cosine_threshold"],
)
rows.append(
{
"pair": pair["pair"],
"split": pair["split"],
"variant": variant,
"proposals": proposals,
"metrics": metrics(proposals, gold[pair["pair"]]),
"choice": choice,
}
)
save_json(
"outputs/evaluation.json",
{
"rows": rows,
"populations": {"calibration_pairs": 12, "test_pairs": 12},
"applied": False,
"semantics": "proposals only; transported mass is not probability",
},
)
def exercise(config):
protected = {
p: sha(p)
for p in [
"outputs/features.json",
"outputs/calibration.json",
"outputs/evaluation.json",
]
}
pair = read_json("data/generated/input/pages.json")[12]
original = list(pair["a"])
pair["a"][0] *= 10
total = sum(pair["a"])
pair["a"] = [v / total for v in pair["a"]]
feature = read_json("outputs/features.json")["rows"][12]
gold = read_json("data/generated/labels/gold.json")[12]
choices = read_json("outputs/calibration.json")["choices"]
rows = []
for variant, c in choices.items():
solution = (
None
if variant == "top1"
else sinkhorn_unbalanced(
pair["a"],
pair["b"],
feature["cost"],
c["epsilon"],
math.inf if variant == "balanced" else c["rho"],
)
)
proposals = propose(
pair,
feature,
variant,
solution,
c["link_ratio"],
c["row_ratio"],
c["cosine_threshold"],
)
rows.append(
{
"variant": variant,
"solution": solution,
"proposals": proposals,
"metrics": metrics(proposals, gold),
}
)
save_json(
"outputs/exercise.json",
{
"pair": 12,
"source_label": pair["source"][0]["label"],
"original_a": original,
"changed_a": pair["a"],
"b": pair["b"],
"multiplier": 10,
"rows": rows,
"protected_sha256": protected,
"main_unchanged": all(sha(p) == v for p, v in protected.items()),
"applied": False,
},
)
Étape 05 — Évaluer le mapping, pas seulement son coût
Gold plusieurs-à-plusieurs
Erreur : Une faible objective est interprétée comme une bonne ontologie
Correction : Rapprocher du gold et analyser les erreurs de définition avec leurs textes.
Fichier src/ged_migration_taxonomies/run.py
import itertools, math
import numpy as np
from .config import read_json, save_json, sha
from .transport import sinkhorn_unbalanced, compare_pot
from .proposals import propose, metrics
def inspect_mechanism(config):
a = [0.7, 0.3]
b = [0.4, 0.6]
cost = [[0.1, 0.9], [0.8, 0.2]]
reference = compare_pot(a, b, cost, 0.1, 1.0, 0.7)
permutation = sinkhorn_unbalanced(
a[::-1], b[::-1], np.asarray(cost)[::-1, ::-1], 0.1, 1.0, 0.7
)
if (
np.max(
np.abs(
np.asarray(reference["ours"]["plan"])
- np.asarray(permutation["plan"])[::-1, ::-1]
)
)
> 1e-10
):
raise AssertionError("Permutation failure")
zero = sinkhorn_unbalanced([1.0, 0.0], [0.4, 0.6], cost, 0.1, 1.0)
save_json(
"outputs/mechanism.json",
{
"a": a,
"b": b,
"cost": cost,
"reference": reference,
"permutation": permutation,
"zero_mass": zero,
"status": "PASS",
},
)
def compute_all(config):
pairs = read_json("data/generated/input/pages.json")
features = {r["pair"]: r for r in read_json("outputs/features.json")["rows"]}
solutions = []
for pair in pairs:
for eps in config["epsilon_grid"]:
solutions.append(
{
"pair": pair["pair"],
"variant": "balanced",
"epsilon": eps,
"rho": None,
"solution": sinkhorn_unbalanced(
pair["a"],
pair["b"],
features[pair["pair"]]["cost"],
eps,
math.inf,
),
}
)
for rho in config["rho_grid"]:
solutions.append(
{
"pair": pair["pair"],
"variant": "unbalanced",
"epsilon": eps,
"rho": rho,
"solution": sinkhorn_unbalanced(
pair["a"],
pair["b"],
features[pair["pair"]]["cost"],
eps,
rho,
tolerance=config["solver_tolerance"],
max_iterations=config["max_iterations"],
),
}
)
save_json("outputs/plans.json", solutions)
def calibrate(config):
pairs = read_json("data/generated/input/pages.json")
features = {r["pair"]: r for r in read_json("outputs/features.json")["rows"]}
plans = read_json("outputs/plans.json")
gold = {g["pair"]: g for g in read_json("data/generated/labels/validation.json")}
if set(gold) != set(range(12)):
raise ValueError("Calibration gold only required")
lookup = {
(r["pair"], r["variant"], r["epsilon"], r["rho"]): r["solution"] for r in plans
}
choices = {}
tables = {}
for variant in ["top1", "balanced", "unbalanced"]:
options = (
[(None, None, None, None, t) for t in config["cosine_threshold_grid"]]
if variant == "top1"
else [
(e, r, l, m, None)
for e in config["epsilon_grid"]
for r in ([None] if variant == "balanced" else config["rho_grid"])
for l in config["link_mass_ratio_grid"]
for m in config["minimum_row_mass_ratio_grid"]
]
)
rows = []
for eps, rho, link, row, cos in options:
total = {"tp": 0, "fp": 0, "fn": 0}
for pair in pairs[:12]:
solution = (
None
if variant == "top1"
else lookup[pair["pair"], variant, eps, rho]
)
result = metrics(
propose(
pair, features[pair["pair"]], variant, solution, link, row, cos
),
gold[pair["pair"]],
)
for k in total:
total[k] += result[k]
score = (
2 * total["tp"] / (2 * total["tp"] + total["fp"] + total["fn"])
if 2 * total["tp"] + total["fp"] + total["fn"]
else 0.0
)
rows.append(
{
"epsilon": eps,
"rho": rho,
"link_ratio": link,
"row_ratio": row,
"cosine_threshold": cos,
"counts": total,
"micro_F1": score,
}
)
# Stable predefined grid order breaks ties, independent of test labels.
winner = max(range(len(rows)), key=lambda i: (rows[i]["micro_F1"], -i))
choices[variant] = dict(rows[winner], grid_index=winner)
tables[variant] = rows
save_json(
"outputs/calibration.json",
{
"pairs": list(range(12)),
"choices": choices,
"grid": tables,
"tie": "first in predefined grid",
"test_gold_visible": False,
},
)
def evaluate(config):
pairs = read_json("data/generated/input/pages.json")
features = {r["pair"]: r for r in read_json("outputs/features.json")["rows"]}
plans = read_json("outputs/plans.json")
gold = {g["pair"]: g for g in read_json("data/generated/labels/gold.json")}
selected = read_json("outputs/calibration.json")["choices"]
lookup = {
(r["pair"], r["variant"], r["epsilon"], r["rho"]): r["solution"] for r in plans
}
rows = []
for pair in pairs:
for variant, choice in selected.items():
solution = (
None
if variant == "top1"
else lookup[pair["pair"], variant, choice["epsilon"], choice["rho"]]
)
proposals = propose(
pair,
features[pair["pair"]],
variant,
solution,
choice["link_ratio"],
choice["row_ratio"],
choice["cosine_threshold"],
)
rows.append(
{
"pair": pair["pair"],
"split": pair["split"],
"variant": variant,
"proposals": proposals,
"metrics": metrics(proposals, gold[pair["pair"]]),
"choice": choice,
}
)
save_json(
"outputs/evaluation.json",
{
"rows": rows,
"populations": {"calibration_pairs": 12, "test_pairs": 12},
"applied": False,
"semantics": "proposals only; transported mass is not probability",
},
)
def exercise(config):
protected = {
p: sha(p)
for p in [
"outputs/features.json",
"outputs/calibration.json",
"outputs/evaluation.json",
]
}
pair = read_json("data/generated/input/pages.json")[12]
original = list(pair["a"])
pair["a"][0] *= 10
total = sum(pair["a"])
pair["a"] = [v / total for v in pair["a"]]
feature = read_json("outputs/features.json")["rows"][12]
gold = read_json("data/generated/labels/gold.json")[12]
choices = read_json("outputs/calibration.json")["choices"]
rows = []
for variant, c in choices.items():
solution = (
None
if variant == "top1"
else sinkhorn_unbalanced(
pair["a"],
pair["b"],
feature["cost"],
c["epsilon"],
math.inf if variant == "balanced" else c["rho"],
)
)
proposals = propose(
pair,
feature,
variant,
solution,
c["link_ratio"],
c["row_ratio"],
c["cosine_threshold"],
)
rows.append(
{
"variant": variant,
"solution": solution,
"proposals": proposals,
"metrics": metrics(proposals, gold),
}
)
save_json(
"outputs/exercise.json",
{
"pair": 12,
"source_label": pair["source"][0]["label"],
"original_a": original,
"changed_a": pair["a"],
"b": pair["b"],
"multiplier": 10,
"rows": rows,
"protected_sha256": protected,
"main_unchanged": all(sha(p) == v for p, v in protected.items()),
"applied": False,
},
)
Étape 06 — Modifier le poids d'une rubrique dominante
Sensibilité aux distributions
Erreur : Le solveur est présenté comme lisant l'intention du métier
Correction : Montrer les hypothèses de charges et soumettre les changements au fichier de revue.
Fichier src/ged_migration_taxonomies/run.py
import itertools, math
import numpy as np
from .config import read_json, save_json, sha
from .transport import sinkhorn_unbalanced, compare_pot
from .proposals import propose, metrics
def inspect_mechanism(config):
a = [0.7, 0.3]
b = [0.4, 0.6]
cost = [[0.1, 0.9], [0.8, 0.2]]
reference = compare_pot(a, b, cost, 0.1, 1.0, 0.7)
permutation = sinkhorn_unbalanced(
a[::-1], b[::-1], np.asarray(cost)[::-1, ::-1], 0.1, 1.0, 0.7
)
if (
np.max(
np.abs(
np.asarray(reference["ours"]["plan"])
- np.asarray(permutation["plan"])[::-1, ::-1]
)
)
> 1e-10
):
raise AssertionError("Permutation failure")
zero = sinkhorn_unbalanced([1.0, 0.0], [0.4, 0.6], cost, 0.1, 1.0)
save_json(
"outputs/mechanism.json",
{
"a": a,
"b": b,
"cost": cost,
"reference": reference,
"permutation": permutation,
"zero_mass": zero,
"status": "PASS",
},
)
def compute_all(config):
pairs = read_json("data/generated/input/pages.json")
features = {r["pair"]: r for r in read_json("outputs/features.json")["rows"]}
solutions = []
for pair in pairs:
for eps in config["epsilon_grid"]:
solutions.append(
{
"pair": pair["pair"],
"variant": "balanced",
"epsilon": eps,
"rho": None,
"solution": sinkhorn_unbalanced(
pair["a"],
pair["b"],
features[pair["pair"]]["cost"],
eps,
math.inf,
),
}
)
for rho in config["rho_grid"]:
solutions.append(
{
"pair": pair["pair"],
"variant": "unbalanced",
"epsilon": eps,
"rho": rho,
"solution": sinkhorn_unbalanced(
pair["a"],
pair["b"],
features[pair["pair"]]["cost"],
eps,
rho,
tolerance=config["solver_tolerance"],
max_iterations=config["max_iterations"],
),
}
)
save_json("outputs/plans.json", solutions)
def calibrate(config):
pairs = read_json("data/generated/input/pages.json")
features = {r["pair"]: r for r in read_json("outputs/features.json")["rows"]}
plans = read_json("outputs/plans.json")
gold = {g["pair"]: g for g in read_json("data/generated/labels/validation.json")}
if set(gold) != set(range(12)):
raise ValueError("Calibration gold only required")
lookup = {
(r["pair"], r["variant"], r["epsilon"], r["rho"]): r["solution"] for r in plans
}
choices = {}
tables = {}
for variant in ["top1", "balanced", "unbalanced"]:
options = (
[(None, None, None, None, t) for t in config["cosine_threshold_grid"]]
if variant == "top1"
else [
(e, r, l, m, None)
for e in config["epsilon_grid"]
for r in ([None] if variant == "balanced" else config["rho_grid"])
for l in config["link_mass_ratio_grid"]
for m in config["minimum_row_mass_ratio_grid"]
]
)
rows = []
for eps, rho, link, row, cos in options:
total = {"tp": 0, "fp": 0, "fn": 0}
for pair in pairs[:12]:
solution = (
None
if variant == "top1"
else lookup[pair["pair"], variant, eps, rho]
)
result = metrics(
propose(
pair, features[pair["pair"]], variant, solution, link, row, cos
),
gold[pair["pair"]],
)
for k in total:
total[k] += result[k]
score = (
2 * total["tp"] / (2 * total["tp"] + total["fp"] + total["fn"])
if 2 * total["tp"] + total["fp"] + total["fn"]
else 0.0
)
rows.append(
{
"epsilon": eps,
"rho": rho,
"link_ratio": link,
"row_ratio": row,
"cosine_threshold": cos,
"counts": total,
"micro_F1": score,
}
)
# Stable predefined grid order breaks ties, independent of test labels.
winner = max(range(len(rows)), key=lambda i: (rows[i]["micro_F1"], -i))
choices[variant] = dict(rows[winner], grid_index=winner)
tables[variant] = rows
save_json(
"outputs/calibration.json",
{
"pairs": list(range(12)),
"choices": choices,
"grid": tables,
"tie": "first in predefined grid",
"test_gold_visible": False,
},
)
def evaluate(config):
pairs = read_json("data/generated/input/pages.json")
features = {r["pair"]: r for r in read_json("outputs/features.json")["rows"]}
plans = read_json("outputs/plans.json")
gold = {g["pair"]: g for g in read_json("data/generated/labels/gold.json")}
selected = read_json("outputs/calibration.json")["choices"]
lookup = {
(r["pair"], r["variant"], r["epsilon"], r["rho"]): r["solution"] for r in plans
}
rows = []
for pair in pairs:
for variant, choice in selected.items():
solution = (
None
if variant == "top1"
else lookup[pair["pair"], variant, choice["epsilon"], choice["rho"]]
)
proposals = propose(
pair,
features[pair["pair"]],
variant,
solution,
choice["link_ratio"],
choice["row_ratio"],
choice["cosine_threshold"],
)
rows.append(
{
"pair": pair["pair"],
"split": pair["split"],
"variant": variant,
"proposals": proposals,
"metrics": metrics(proposals, gold[pair["pair"]]),
"choice": choice,
}
)
save_json(
"outputs/evaluation.json",
{
"rows": rows,
"populations": {"calibration_pairs": 12, "test_pairs": 12},
"applied": False,
"semantics": "proposals only; transported mass is not probability",
},
)
def exercise(config):
protected = {
p: sha(p)
for p in [
"outputs/features.json",
"outputs/calibration.json",
"outputs/evaluation.json",
]
}
pair = read_json("data/generated/input/pages.json")[12]
original = list(pair["a"])
pair["a"][0] *= 10
total = sum(pair["a"])
pair["a"] = [v / total for v in pair["a"]]
feature = read_json("outputs/features.json")["rows"][12]
gold = read_json("data/generated/labels/gold.json")[12]
choices = read_json("outputs/calibration.json")["choices"]
rows = []
for variant, c in choices.items():
solution = (
None
if variant == "top1"
else sinkhorn_unbalanced(
pair["a"],
pair["b"],
feature["cost"],
c["epsilon"],
math.inf if variant == "balanced" else c["rho"],
)
)
proposals = propose(
pair,
feature,
variant,
solution,
c["link_ratio"],
c["row_ratio"],
c["cosine_threshold"],
)
rows.append(
{
"variant": variant,
"solution": solution,
"proposals": proposals,
"metrics": metrics(proposals, gold),
}
)
save_json(
"outputs/exercise.json",
{
"pair": 12,
"source_label": pair["source"][0]["label"],
"original_a": original,
"changed_a": pair["a"],
"b": pair["b"],
"multiplier": 10,
"rows": rows,
"protected_sha256": protected,
"main_unchanged": all(sha(p) == v for p, v in protected.items()),
"applied": False,
},
)
Sources
Chizat et al., Scaling Algorithms for Unbalanced Transport Problems décrit la famille de mises à l’échelle. POT 0.9.7 documente le solveur de référence et son choix de régularisation. SKOS Reference distingue les relations d’organisation et de mapping ; les propositions du TP ne les affirment pas. E5 figé.
Interprétation du parcours de développement
Sur les 12 paires test, le F1 moyen par paire vaut 0,875 pour Top 1, 0,591636 pour le transport équilibré et 0,742633 pour le transport non équilibré. Les totaux TP/FP/FN sont respectivement 84/0/24, 49/9/59 et 74/18/34. Les deux transports créent respectivement 2 et 1 faux liens sur des concepts sans équivalent ; Top 1 en crée 0. Les 192 plans convergent et le contrôle POT 2×2 a une erreur maximale de 2,4871e-12. Le solveur est vérifié, sans gain de pertinence sur la baseline. Pour ce corpus, Top 1 est le choix étayé par la qualité mesurée ; un besoin de répartition globale des masses exigerait une nouvelle évaluation métier.
L’exercice multiplie la charge de la source 0 par dix puis renormalise a ; les coûts, la marge cible et les seuils restent fixes. Une variation des masses de transport ne représente pas une variation de probabilité de lien. Les deux heatmaps affichent leurs propres échelles de couleur : comparer les valeurs des barres, pas les couleurs seules.
Observations de développement
Valeurs recalculées depuis les sorties brutes de ce parcours. Elles ne sont pas des seuils de gain ; le parcours depuis le ZIP les recalcule sur les mêmes populations.
{
"status": "PASS",
"pairs": 24,
"calibration_pairs": 12,
"test_pairs": 12,
"source_concepts": 192,
"target_concepts": 240,
"pieces": 1536,
"plans": 192,
"calibration_choices": {
"top1": {
"epsilon": null,
"rho": null,
"link_ratio": null,
"row_ratio": null,
"cosine_threshold": 0.95,
"counts": {
"tp": 84,
"fp": 0,
"fn": 24
},
"micro_F1": 0.875,
"grid_index": 19
},
"balanced": {
"epsilon": 0.03,
"rho": null,
"link_ratio": 0.3,
"row_ratio": 0.1,
"cosine_threshold": null,
"counts": {
"tp": 66,
"fp": 3,
"fn": 42
},
"micro_F1": 0.7457627118644068,
"grid_index": 4
},
"unbalanced": {
"epsilon": 0.03,
"rho": 0.1,
"link_ratio": 0.3,
"row_ratio": 0.1,
"cosine_threshold": null,
"counts": {
"tp": 83,
"fp": 4,
"fn": 25
},
"micro_F1": 0.8512820512820513,
"grid_index": 4
}
},
"test_metrics": [
{
"pair": 12,
"variant": "top1",
"true_links": 9,
"proposed_links": 7,
"tp": 7,
"fp": 0,
"fn": 2,
"precision": 1.0,
"recall": 0.7777777777777778,
"F1": 0.875,
"covered_source_concepts": 7,
"source_concepts": 8,
"false_links_without_equivalent": 0,
"without_equivalent_concepts": 1,
"review_rows": 1,
"excluded_rows": 0,
"applied": false,
"skos_exactMatch_assertions": 0
},
{
"pair": 12,
"variant": "balanced",
"true_links": 9,
"proposed_links": 4,
"tp": 4,
"fp": 0,
"fn": 5,
"precision": 1.0,
"recall": 0.4444444444444444,
"F1": 0.6153846153846154,
"covered_source_concepts": 4,
"source_concepts": 8,
"false_links_without_equivalent": 0,
"without_equivalent_concepts": 1,
"review_rows": 3,
"excluded_rows": 1,
"applied": false,
"skos_exactMatch_assertions": 0
},
{
"pair": 12,
"variant": "unbalanced",
"true_links": 9,
"proposed_links": 6,
"tp": 6,
"fp": 0,
"fn": 3,
"precision": 1.0,
"recall": 0.6666666666666666,
"F1": 0.8,
"covered_source_concepts": 6,
"source_concepts": 8,
"false_links_without_equivalent": 0,
"without_equivalent_concepts": 1,
"review_rows": 1,
"excluded_rows": 1,
"applied": false,
"skos_exactMatch_assertions": 0
},
{
"pair": 13,
"variant": "top1",
"true_links": 9,
"proposed_links": 7,
"tp": 7,
"fp": 0,
"fn": 2,
"precision": 1.0,
"recall": 0.7777777777777778,
"F1": 0.875,
"covered_source_concepts": 7,
"source_concepts": 8,
"false_links_without_equivalent": 0,
"without_equivalent_concepts": 1,
"review_rows": 1,
"excluded_rows": 0,
"applied": false,
"skos_exactMatch_assertions": 0
},
{
"pair": 13,
"variant": "balanced",
"true_links": 9,
"proposed_links": 4,
"tp": 4,
"fp": 0,
"fn": 5,
"precision": 1.0,
"recall": 0.4444444444444444,
"F1": 0.6153846153846154,
"covered_source_concepts": 4,
"source_concepts": 8,
"false_links_without_equivalent": 0,
"without_equivalent_concepts": 1,
"review_rows": 3,
"excluded_rows": 1,
"applied": false,
"skos_exactMatch_assertions": 0
},
{
"pair": 13,
"variant": "unbalanced",
"true_links": 9,
"proposed_links": 8,
"tp": 7,
"fp": 1,
"fn": 2,
"precision": 0.875,
"recall": 0.7777777777777778,
"F1": 0.8235294117647058,
"covered_source_concepts": 6,
"source_concepts": 8,
"false_links_without_equivalent": 0,
"without_equivalent_concepts": 1,
"review_rows": 1,
"excluded_rows": 1,
"applied": false,
"skos_exactMatch_assertions": 0
},
{
"pair": 14,
"variant": "top1",
"true_links": 9,
"proposed_links": 7,
"tp": 7,
"fp": 0,
"fn": 2,
"precision": 1.0,
"recall": 0.7777777777777778,
"F1": 0.875,
"covered_source_concepts": 7,
"source_concepts": 8,
"false_links_without_equivalent": 0,
"without_equivalent_concepts": 1,
"review_rows": 1,
"excluded_rows": 0,
"applied": false,
"skos_exactMatch_assertions": 0
},
{
"pair": 14,
"variant": "balanced",
"true_links": 9,
"proposed_links": 5,
"tp": 5,
"fp": 0,
"fn": 4,
"precision": 1.0,
"recall": 0.5555555555555556,
"F1": 0.7142857142857143,
"covered_source_concepts": 5,
"source_concepts": 8,
"false_links_without_equivalent": 0,
"without_equivalent_concepts": 1,
"review_rows": 2,
"excluded_rows": 1,
"applied": false,
"skos_exactMatch_assertions": 0
},
{
"pair": 14,
"variant": "unbalanced",
"true_links": 9,
"proposed_links": 9,
"tp": 7,
"fp": 2,
"fn": 2,
"precision": 0.7777777777777778,
"recall": 0.7777777777777778,
"F1": 0.7777777777777778,
"covered_source_concepts": 6,
"source_concepts": 8,
"false_links_without_equivalent": 0,
"without_equivalent_concepts": 1,
"review_rows": 1,
"excluded_rows": 1,
"applied": false,
"skos_exactMatch_assertions": 0
},
{
"pair": 15,
"variant": "top1",
"true_links": 9,
"proposed_links": 7,
"tp": 7,
"fp": 0,
"fn": 2,
"precision": 1.0,
"recall": 0.7777777777777778,
"F1": 0.875,
"covered_source_concepts": 7,
"source_concepts": 8,
"false_links_without_equivalent": 0,
"without_equivalent_concepts": 1,
"review_rows": 1,
"excluded_rows": 0,
"applied": false,
"skos_exactMatch_assertions": 0
},
{
"pair": 15,
"variant": "balanced",
"true_links": 9,
"proposed_links": 6,
"tp": 3,
"fp": 3,
"fn": 6,
"precision": 0.5,
"recall": 0.3333333333333333,
"F1": 0.4,
"covered_source_concepts": 6,
"source_concepts": 8,
"false_links_without_equivalent": 1,
"without_equivalent_concepts": 1,
"review_rows": 2,
"excluded_rows": 1,
"applied": false,
"skos_exactMatch_assertions": 0
},
{
"pair": 15,
"variant": "unbalanced",
"true_links": 9,
"proposed_links": 11,
"tp": 6,
"fp": 5,
"fn": 3,
"precision": 0.5454545454545454,
"recall": 0.6666666666666666,
"F1": 0.6,
"covered_source_concepts": 8,
"source_concepts": 8,
"false_links_without_equivalent": 1,
"without_equivalent_concepts": 1,
"review_rows": 0,
"excluded_rows": 1,
"applied": false,
"skos_exactMatch_assertions": 0
},
{
"pair": 16,
"variant": "top1",
"true_links": 9,
"proposed_links": 7,
"tp": 7,
"fp": 0,
"fn": 2,
"precision": 1.0,
"recall": 0.7777777777777778,
"F1": 0.875,
"covered_source_concepts": 7,
"source_concepts": 8,
"false_links_without_equivalent": 0,
"without_equivalent_concepts": 1,
"review_rows": 1,
"excluded_rows": 0,
"applied": false,
"skos_exactMatch_assertions": 0
},
{
"pair": 16,
"variant": "balanced",
"true_links": 9,
"proposed_links": 4,
"tp": 4,
"fp": 0,
"fn": 5,
"precision": 1.0,
"recall": 0.4444444444444444,
"F1": 0.6153846153846154,
"covered_source_concepts": 4,
"source_concepts": 8,
"false_links_without_equivalent": 0,
"without_equivalent_concepts": 1,
"review_rows": 4,
"excluded_rows": 0,
"applied": false,
"skos_exactMatch_assertions": 0
},
{
"pair": 16,
"variant": "unbalanced",
"true_links": 9,
"proposed_links": 6,
"tp": 6,
"fp": 0,
"fn": 3,
"precision": 1.0,
"recall": 0.6666666666666666,
"F1": 0.8,
"covered_source_concepts": 6,
"source_concepts": 8,
"false_links_without_equivalent": 0,
"without_equivalent_concepts": 1,
"review_rows": 1,
"excluded_rows": 1,
"applied": false,
"skos_exactMatch_assertions": 0
},
{
"pair": 17,
"variant": "top1",
"true_links": 9,
"proposed_links": 7,
"tp": 7,
"fp": 0,
"fn": 2,
"precision": 1.0,
"recall": 0.7777777777777778,
"F1": 0.875,
"covered_source_concepts": 7,
"source_concepts": 8,
"false_links_without_equivalent": 0,
"without_equivalent_concepts": 1,
"review_rows": 1,
"excluded_rows": 0,
"applied": false,
"skos_exactMatch_assertions": 0
},
{
"pair": 17,
"variant": "balanced",
"true_links": 9,
"proposed_links": 5,
"tp": 5,
"fp": 0,
"fn": 4,
"precision": 1.0,
"recall": 0.5555555555555556,
"F1": 0.7142857142857143,
"covered_source_concepts": 5,
"source_concepts": 8,
"false_links_without_equivalent": 0,
"without_equivalent_concepts": 1,
"review_rows": 2,
"excluded_rows": 1,
"applied": false,
"skos_exactMatch_assertions": 0
},
{
"pair": 17,
"variant": "unbalanced",
"true_links": 9,
"proposed_links": 7,
"tp": 7,
"fp": 0,
"fn": 2,
"precision": 1.0,
"recall": 0.7777777777777778,
"F1": 0.875,
"covered_source_concepts": 6,
"source_concepts": 8,
"false_links_without_equivalent": 0,
"without_equivalent_concepts": 1,
"review_rows": 1,
"excluded_rows": 1,
"applied": false,
"skos_exactMatch_assertions": 0
},
{
"pair": 18,
"variant": "top1",
"true_links": 9,
"proposed_links": 7,
"tp": 7,
"fp": 0,
"fn": 2,
"precision": 1.0,
"recall": 0.7777777777777778,
"F1": 0.875,
"covered_source_concepts": 7,
"source_concepts": 8,
"false_links_without_equivalent": 0,
"without_equivalent_concepts": 1,
"review_rows": 1,
"excluded_rows": 0,
"applied": false,
"skos_exactMatch_assertions": 0
},
{
"pair": 18,
"variant": "balanced",
"true_links": 9,
"proposed_links": 5,
"tp": 4,
"fp": 1,
"fn": 5,
"precision": 0.8,
"recall": 0.4444444444444444,
"F1": 0.5714285714285714,
"covered_source_concepts": 5,
"source_concepts": 8,
"false_links_without_equivalent": 0,
"without_equivalent_concepts": 1,
"review_rows": 3,
"excluded_rows": 0,
"applied": false,
"skos_exactMatch_assertions": 0
},
{
"pair": 18,
"variant": "unbalanced",
"true_links": 9,
"proposed_links": 8,
"tp": 6,
"fp": 2,
"fn": 3,
"precision": 0.75,
"recall": 0.6666666666666666,
"F1": 0.7058823529411765,
"covered_source_concepts": 7,
"source_concepts": 8,
"false_links_without_equivalent": 0,
"without_equivalent_concepts": 1,
"review_rows": 1,
"excluded_rows": 0,
"applied": false,
"skos_exactMatch_assertions": 0
},
{
"pair": 19,
"variant": "top1",
"true_links": 9,
"proposed_links": 7,
"tp": 7,
"fp": 0,
"fn": 2,
"precision": 1.0,
"recall": 0.7777777777777778,
"F1": 0.875,
"covered_source_concepts": 7,
"source_concepts": 8,
"false_links_without_equivalent": 0,
"without_equivalent_concepts": 1,
"review_rows": 1,
"excluded_rows": 0,
"applied": false,
"skos_exactMatch_assertions": 0
},
{
"pair": 19,
"variant": "balanced",
"true_links": 9,
"proposed_links": 5,
"tp": 5,
"fp": 0,
"fn": 4,
"precision": 1.0,
"recall": 0.5555555555555556,
"F1": 0.7142857142857143,
"covered_source_concepts": 5,
"source_concepts": 8,
"false_links_without_equivalent": 0,
"without_equivalent_concepts": 1,
"review_rows": 2,
"excluded_rows": 1,
"applied": false,
"skos_exactMatch_assertions": 0
},
{
"pair": 19,
"variant": "unbalanced",
"true_links": 9,
"proposed_links": 8,
"tp": 7,
"fp": 1,
"fn": 2,
"precision": 0.875,
"recall": 0.7777777777777778,
"F1": 0.8235294117647058,
"covered_source_concepts": 6,
"source_concepts": 8,
"false_links_without_equivalent": 0,
"without_equivalent_concepts": 1,
"review_rows": 1,
"excluded_rows": 1,
"applied": false,
"skos_exactMatch_assertions": 0
},
{
"pair": 20,
"variant": "top1",
"true_links": 9,
"proposed_links": 7,
"tp": 7,
"fp": 0,
"fn": 2,
"precision": 1.0,
"recall": 0.7777777777777778,
"F1": 0.875,
"covered_source_concepts": 7,
"source_concepts": 8,
"false_links_without_equivalent": 0,
"without_equivalent_concepts": 1,
"review_rows": 1,
"excluded_rows": 0,
"applied": false,
"skos_exactMatch_assertions": 0
},
{
"pair": 20,
"variant": "balanced",
"true_links": 9,
"proposed_links": 4,
"tp": 4,
"fp": 0,
"fn": 5,
"precision": 1.0,
"recall": 0.4444444444444444,
"F1": 0.6153846153846154,
"covered_source_concepts": 4,
"source_concepts": 8,
"false_links_without_equivalent": 0,
"without_equivalent_concepts": 1,
"review_rows": 3,
"excluded_rows": 1,
"applied": false,
"skos_exactMatch_assertions": 0
},
{
"pair": 20,
"variant": "unbalanced",
"true_links": 9,
"proposed_links": 6,
"tp": 6,
"fp": 0,
"fn": 3,
"precision": 1.0,
"recall": 0.6666666666666666,
"F1": 0.8,
"covered_source_concepts": 5,
"source_concepts": 8,
"false_links_without_equivalent": 0,
"without_equivalent_concepts": 1,
"review_rows": 2,
"excluded_rows": 1,
"applied": false,
"skos_exactMatch_assertions": 0
},
{
"pair": 21,
"variant": "top1",
"true_links": 9,
"proposed_links": 7,
"tp": 7,
"fp": 0,
"fn": 2,
"precision": 1.0,
"recall": 0.7777777777777778,
"F1": 0.875,
"covered_source_concepts": 7,
"source_concepts": 8,
"false_links_without_equivalent": 0,
"without_equivalent_concepts": 1,
"review_rows": 1,
"excluded_rows": 0,
"applied": false,
"skos_exactMatch_assertions": 0
},
{
"pair": 21,
"variant": "balanced",
"true_links": 9,
"proposed_links": 6,
"tp": 5,
"fp": 1,
"fn": 4,
"precision": 0.8333333333333334,
"recall": 0.5555555555555556,
"F1": 0.6666666666666666,
"covered_source_concepts": 5,
"source_concepts": 8,
"false_links_without_equivalent": 0,
"without_equivalent_concepts": 1,
"review_rows": 3,
"excluded_rows": 0,
"applied": false,
"skos_exactMatch_assertions": 0
},
{
"pair": 21,
"variant": "unbalanced",
"true_links": 9,
"proposed_links": 8,
"tp": 6,
"fp": 2,
"fn": 3,
"precision": 0.75,
"recall": 0.6666666666666666,
"F1": 0.7058823529411765,
"covered_source_concepts": 6,
"source_concepts": 8,
"false_links_without_equivalent": 0,
"without_equivalent_concepts": 1,
"review_rows": 1,
"excluded_rows": 1,
"applied": false,
"skos_exactMatch_assertions": 0
},
{
"pair": 22,
"variant": "top1",
"true_links": 9,
"proposed_links": 7,
"tp": 7,
"fp": 0,
"fn": 2,
"precision": 1.0,
"recall": 0.7777777777777778,
"F1": 0.875,
"covered_source_concepts": 7,
"source_concepts": 8,
"false_links_without_equivalent": 0,
"without_equivalent_concepts": 1,
"review_rows": 1,
"excluded_rows": 0,
"applied": false,
"skos_exactMatch_assertions": 0
},
{
"pair": 22,
"variant": "balanced",
"true_links": 9,
"proposed_links": 5,
"tp": 3,
"fp": 2,
"fn": 6,
"precision": 0.6,
"recall": 0.3333333333333333,
"F1": 0.42857142857142855,
"covered_source_concepts": 5,
"source_concepts": 8,
"false_links_without_equivalent": 1,
"without_equivalent_concepts": 1,
"review_rows": 3,
"excluded_rows": 1,
"applied": false,
"skos_exactMatch_assertions": 0
},
{
"pair": 22,
"variant": "unbalanced",
"true_links": 9,
"proposed_links": 9,
"tp": 6,
"fp": 3,
"fn": 3,
"precision": 0.6666666666666666,
"recall": 0.6666666666666666,
"F1": 0.6666666666666666,
"covered_source_concepts": 6,
"source_concepts": 8,
"false_links_without_equivalent": 0,
"without_equivalent_concepts": 1,
"review_rows": 1,
"excluded_rows": 1,
"applied": false,
"skos_exactMatch_assertions": 0
},
{
"pair": 23,
"variant": "top1",
"true_links": 9,
"proposed_links": 7,
"tp": 7,
"fp": 0,
"fn": 2,
"precision": 1.0,
"recall": 0.7777777777777778,
"F1": 0.875,
"covered_source_concepts": 7,
"source_concepts": 8,
"false_links_without_equivalent": 0,
"without_equivalent_concepts": 1,
"review_rows": 1,
"excluded_rows": 0,
"applied": false,
"skos_exactMatch_assertions": 0
},
{
"pair": 23,
"variant": "balanced",
"true_links": 9,
"proposed_links": 5,
"tp": 3,
"fp": 2,
"fn": 6,
"precision": 0.6,
"recall": 0.3333333333333333,
"F1": 0.42857142857142855,
"covered_source_concepts": 4,
"source_concepts": 8,
"false_links_without_equivalent": 0,
"without_equivalent_concepts": 1,
"review_rows": 3,
"excluded_rows": 1,
"applied": false,
"skos_exactMatch_assertions": 0
},
{
"pair": 23,
"variant": "unbalanced",
"true_links": 9,
"proposed_links": 6,
"tp": 4,
"fp": 2,
"fn": 5,
"precision": 0.6666666666666666,
"recall": 0.4444444444444444,
"F1": 0.5333333333333333,
"covered_source_concepts": 5,
"source_concepts": 8,
"false_links_without_equivalent": 0,
"without_equivalent_concepts": 1,
"review_rows": 2,
"excluded_rows": 1,
"applied": false,
"skos_exactMatch_assertions": 0
}
],
"POT_max_error": 2.4871216197652757e-12,
"proposals_applied": false
}