Alignement d'entités dans un graphe de connaissances
Décide laquelle des 450 paires candidates de deux catalogues de bières décrit le même produit, à l’aide d’une question Score et de trois Noul compagnons qui signalent les champs en désaccord.
Un problème clé des graphes de connaissances est de décider si une entité entrante duplique une entité existante, surtout quand le seul matériau disponible est du langage naturel venu de sources disparates. Étant donné des paires potentiellement dupliquées, un unique Score TypeSafe décide si chaque paire est un doublon, ou si elle mérite l’examen attentif d’un curateur.
Suppose que deux sources de données décrivent des ensembles qui se recouvrent des mêmes choses, et que tu doives savoir quelle entrée d’un côté est la même chose que quelle entrée de l’autre. Un graphe de connaissances appelle ces entrées des entités et conserve les faits enregistrés sur chacune. Une première passe, bon marché mais grossière, a déjà comparé les deux sources et retenu 450 paires dignes d’un examen plus attentif. Il reste à porter un jugement sur chaque paire.
Fusionner deux entités à tort est l’erreur la plus coûteuse chaque fait sur l’une ou l’autre entité décrit désormais l’entité fusionnée, et tout ce qui est lié à l’une ou l’autre suit aussi. Revenir en arrière oblige ensuite à déterminer quel fait venait d’où. Rater une correspondance ne laisse qu’un doublon, donc le jugement a besoin d’une troisième option les paires qu’il n’est ni sûr de fusionner ni sûr d’écarter.
Le jugement est une question Score avec un niveau pour chacun des trois résultats
- produit distinct — laisse les deux entités non liées
- liées, mais peut-être pas les mêmes — confie la paire à un curateur qui décidera
- même produit — fusionne-les
On utilise une question Score parce qu’on veut attacher une étiquette sémantique, les criteria du score, directement à chaque résultat, y compris le résultat intermédiaire. Une question Noul pourrait y parvenir indirectement, en appliquant un seuil sur sa sortie, et une question Choice perdrait la relation ordonnée des trois résultats.
Ensuite, pour chaque champ de l’entité que l’on veut considérer, des questions Noul demandant si ces champs correspondent peuvent voyager dans la même requête. Ces nouls apportent au curateur des informations plus détaillées, si le score ne tombe ni dans le niveau « même produit » ni dans le niveau « produit distinct ».
Tu obtiens un route() qui prend une paire candidate et renvoie l’un des trois résultats, sans aucun seuil que tu aies eu à ajuster à tes propres données.
flowchart LR
PAIR["one candidate pair<br/><i>both entities, one state</i>"] --> CALL
subgraph CALL["one request, four questions"]
direction TB
S["<b>Score:</b> how do the two relate?<br/>· different product<br/>· related, but possibly not the same<br/>· same product"]
N["<b>Nouls:</b> one per compared field<br/>· same name?<br/>· same brewery?<br/>· same style?"]
%% invisible link: without an edge these two share a rank, which in a TB
%% subgraph puts them side by side instead of stacked
S ~~~ N
end
S --> R{"round to the<br/>nearest level"}
R -->|"different"| DROP["leave unlinked"]
R -->|"same"| M["assert sameAs"]
%% the queue is last so the dotted edge below reaches it without crossing
%% the arrow into `assert sameAs`
R -->|"related"| Q["curator queue"]
N -.->|"which field<br/>they disagree on"| Q
Configuration
pip install matplotlib ipython 'cooksafe>=0.2.0,<0.3.0'
puis définis TYPESAFE_API_KEY. Chaque appel est mis en cache dans json_cache.json, livré avec le cookbook, donc un nouveau rendu rejoue les chiffres publiés sans appeler l’API. Supprime ce fichier pour tout réexécuter en direct.
Les chiffres ci-dessous proviennent de jev-1.12 le 2026-08-11.
import json
import os
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
import matplotlib
import matplotlib.pyplot as plt
from cooksafe import JsonCache, make_playground_link
from IPython.display import Markdown, display
from typesafe_sdk import Noul, Score, TypeSafeClient
matplotlib.use("Agg") # headless render
TYPESAFE_MODEL = "jev-1.12"
MAX_WORKERS = 6 # small pool; the public endpoint rate-limits above roughly eight
client = TypeSafeClient(
api_key=os.environ.get(
"TYPESAFE_API_KEY", "cache-only"
), # keyless kernels replay the cache
base_url=os.environ.get("TYPESAFE_ENDPOINT"),
timeout=120.0,
)
json_cache = JsonCache(Path("json_cache.json"))
Charge les paires candidates
Les paires viennent d’un jeu de benchmark publié, les données de bière de la collection Magellan deux catalogues de bières extraits de sites web différents, déjà réduits à 450 paires par cette première passe grossière. Chaque entité porte quatre champs nom, brasserie, style et teneur en alcool. Chaque paire porte aussi known_same_as, la réponse du benchmark lui-même.
Le texte est laissé exactement tel que publié, sans prétraitement des entités HTML jamais reconverties en caractères, des apostrophes détachées en mots séparés, quelques caractères mal décodés.
Une requête part par paire ce que tu dépenses suit le nombre de paires qu’on t’a confiées, pas la taille de l’une ou l’autre source.
PAIRS = json.loads(Path("candidate_pairs.json").read_text(encoding="utf-8"))
BY_ID = {pair["id"]: pair for pair in PAIRS}
print(f"{len(PAIRS)} candidate pairs. The first one, as the model will see it:")
print(json.dumps({k: PAIRS[0][k] for k in ("entity_a", "entity_b")}, indent=2)[:420])
450 candidate pairs. The first one, as the model will see it:
{
"entity_a": {
"name": "C N Red Imperial Red Ale",
"brewery": "Redwood Lodge",
"style": "American Amber / Red Ale",
"abv": "8.10 %"
},
"entity_b": {
"name": "Kinetic Infrared Imperial Red Ale",
"brewery": "Kinetic Brewing Company",
"style": "American Strong Ale",
"abv": "9.30 %"
}
}
Pose une question Score et trois questions Noul par paire candidate
Les deux entités vont dans un seul état, comme entity_a et entity_b, donc les questions portent sur la paire et non sur chaque côté isolément. Les quatre voyagent dans une seule requête.
Les trois descriptions de niveau ci-dessous sont toute la décision chaque niveau est un résultat. Il n’y a aucune constante de seuil nulle part dans ce fichier. Tu peux aussi écrire ces descriptions avant d’avoir vu un seul score, ce qui n’est pas le cas d’un nombre que tu dois ajuster.
Le niveau intermédiaire est celui qui mérite d’être écrit avec soin. Ici il couvre les variantes, les éditions spéciales et les noms qui pourraient plausiblement désigner l’un ou l’autre produit, de sorte qu’ils arrivent à un curateur au lieu d’être fusionnés ou écartés.
OUTCOME nomme les trois résultats. Le résultat de fusion s’appelle assert sameAs parce que sameAs est la façon standard d’enregistrer que deux entités sont la même chose, et en écrire un est la manière dont la fusion se produit réellement.
Trois des quatre champs reçoivent une question Noul nom, brasserie et style. La teneur en alcool n’en reçoit aucune, parce que comparer deux nombres relève de l’arithmétique calcule-la dans le code si tu la veux. Pour utiliser ceci sur un autre type de données, tu réécris QUESTIONS et LEVELS. Le seul autre code qui connaît la bière, ce sont les deux fonctions qui impriment les résultats, lesquelles nomment les champs.
LEVELS = [
"They describe two different products.",
"They describe closely related products that may or may not be the same one: "
"a variant, a special edition, or a name that could plausibly refer to either.",
"They describe one and the same product.",
]
OUTCOME = {0: "leave unlinked", 1: "curator queue", 2: "assert sameAs"}
QUESTIONS = {
"link_state": Score(
instructions="How do the two entity descriptions relate as products?",
criteria=LEVELS,
),
"same_name": Noul(
instructions="Do the two entities state the same beer name?",
),
"same_brewery": Noul(
instructions="Are the two entities from the same brewery?",
),
"same_style": Noul(
instructions="Do the two entities describe the same beer style?",
),
}
@json_cache
def score(pair_id: str) -> dict:
"""One request about one candidate pair -> the score plus the three noul answers."""
pair = BY_ID[pair_id]
response = client.system_one(
state={"entity_a": pair["entity_a"], "entity_b": pair["entity_b"]},
questions=QUESTIONS,
model=TYPESAFE_MODEL,
)
link = response.answers["link_state"]
return {
"score": link.score,
"probabilities": link.probabilities,
"confidence": link.confidence,
"properties": {
k: response.answers[k].noul for k in QUESTIONS if k != "link_state"
},
# tokens and requests are the durable units; don't cache a derived cost
"input_tokens": response.usage.input_tokens or 0,
"output_tokens": response.usage.output_tokens or 0,
}
def route(score_value: float) -> str:
"""The whole decision rule: the nearest level names the outcome."""
return OUTCOME[min(int(score_value + 0.5), len(LEVELS) - 1)]
def show(pair_id: str) -> None:
pair, result = BY_ID[pair_id], score(pair_id)
print(
f"{pair_id} score {result['score']:.2f} confidence {result['confidence']:.2f}"
f" -> {route(result['score'])}"
)
for side in ("entity_a", "entity_b"):
e = pair[side]
print(f" {e['name'][:44]:<46}{e['brewery'][:30]:<32}{e['style'][:22]}")
nouls = result["properties"]
print(
f" name {nouls['same_name']:.2f} brewery {nouls['same_brewery']:.2f} "
f"style {nouls['same_style']:.2f}"
)
Quatre paires. c446 est un seul produit et c427 en sont deux. Les deux autres tombent dans le niveau intermédiaire pour des raisons différentes c100 a le même nom et la même brasserie, mais les sources formulent son style différemment, tandis que c428 apparie une bière avec une variante fruitée et houblonnée de celle-ci.
for pair_id in ("c446", "c427", "c100", "c428"):
show(pair_id)
print()
c446 score 1.94 confidence 0.92 -> assert sameAs
Thomas Hooker Old Marley Barleywine Thomas Hooker Brewing Company American Barleywine
Thomas Hooker Old Marley Barleywine Thomas Hooker Brewing Company Barley Wine
name 0.97 brewery 0.99 style 0.81
c427 score 0.03 confidence 0.95 -> leave unlinked
Frost Quake Bourbon Barrel Aged Barley Wine Wellington County Brewery American Barleywine
Lompoc Bourbon Barrel Aged Proletariat Red A Lompoc Brewing Amber Ale
name 0.02 brewery 0.09 style 0.08
c100 score 1.30 confidence 0.27 -> curator queue
Belle Gueule Rousse Brasseurs R.J. American Amber / Red A
Belle Gueule Rousse Brasseurs RJ Amber Lager/Vienna
name 0.95 brewery 0.94 style 0.35
c428 score 1.10 confidence 0.77 -> curator queue
Ambleside Amber Ale Bridge Brewing Company American Amber / Red A
Bridge Ambleside Amber Ale - Pomegranate & G Bridge Brewing Company Amber Ale
name 0.63 brewery 0.98 style 0.74
Route chaque paire candidate
# 450 candidate pairs, one request each; a small pool keeps a live run to a few minutes.
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool:
scored = list(pool.map(lambda pair: score(pair["id"]), PAIRS))
scores = [result["score"] for result in scored]
by_outcome: dict[str, list[str]] = {name: [] for name in OUTCOME.values()}
for pair, s in zip(PAIRS, scores):
by_outcome[route(s)].append(pair["id"])
SURFACE, INK, INK2, MUTED = "#fcfcfb", "#0b0b0b", "#52514e", "#898781"
GRID, AXIS, BLUE, ORANGE = "#e1e0d9", "#c3c2b7", "#2a78d6", "#eb6834"
BINS, TOP = 20, len(LEVELS) - 1
counts = [0] * BINS
for s in scores:
counts[min(int(s / TOP * BINS), BINS - 1)] += 1
centers = [(i + 0.5) / BINS * TOP for i in range(BINS)]
queued = [c if route(x) == "curator queue" else 0 for c, x in zip(counts, centers)]
settled = [c if route(x) != "curator queue" else 0 for c, x in zip(counts, centers)]
fig, ax = plt.subplots(figsize=(7.2, 3.6), facecolor=SURFACE)
ax.set_facecolor(SURFACE)
for side in ("top", "right"):
ax.spines[side].set_visible(False)
for side in ("left", "bottom"):
ax.spines[side].set_color(AXIS)
ax.tick_params(colors=MUTED, labelcolor=INK2, labelsize=9)
ax.set_axisbelow(True)
ax.grid(axis="y", color=GRID, linewidth=0.8)
ax.bar(
centers, settled, width=TOP / BINS * 0.9, color=BLUE, label="settled automatically"
)
ax.bar(
centers, queued, width=TOP / BINS * 0.9, color=ORANGE, label="sent to the curator"
)
for edge in (0.5, 1.5):
ax.axvline(edge, color=INK2, linewidth=1, linestyle="--")
ax.set_xticks([0, 0.5, 1, 1.5, 2])
ax.set_xticklabels(["0\ndifferent", "0.5", "1\nrelated", "1.5", "2\nsame"])
ax.set_xlabel("score for the pair", color=INK2, fontsize=9)
ax.set_ylabel("candidate pairs", color=INK2, fontsize=9)
ax.set_title(
f"{len(PAIRS)} candidate pairs, scored once each",
loc="left",
color=INK,
fontsize=11,
)
ax.legend(frameon=False, labelcolor=INK2, fontsize=9)
display(fig)
plt.close(fig)
for name in ("assert sameAs", "curator queue", "leave unlinked"):
n = len(by_outcome[name])
print(f"{name:<16}{n:>5} ({n / len(PAIRS):>5.1%})")
assert sameAs 40 ( 8.9%)
curator queue 50 (11.1%)
leave unlinked 360 (80.0%)
Les deux valeurs de score où route() change de réponse sont les points de coupure. La plupart des paires se règlent 360 obtiennent un score sous le point de coupure inférieur et 40 au-dessus du supérieur, ce qui en laisse 50 au curateur.
Sur cet ensemble, les scores ne se posent pas proprement sur les nombres entiers. La plupart tombent près de 0.25. Deux bières sans rien en commun peuvent tout de même partager un nom de style, et leurs noms de brasserie peuvent se ressembler, donc le modèle donne au niveau intermédiaire une partie de sa probabilité plutôt que rien. Ce qui décide une paire, c’est de quel côté d’un point de coupure elle tombe. Sa proximité avec un niveau n’entre pas en jeu.
Les deux points de coupure ne sont pas également fréquentés. Neuf paires se situent à moins de 0.1 du supérieur, à 1.5, celui qui décide de ce qui est fusionné dans le graphe. Quarante-sept se situent aussi près de l’inférieur, à 0.5, qui décide seulement si un curateur voit la paire. Aucun des deux nombres n’est quelque chose que tu ajustes. Tous deux découlent de la façon dont tu as rédigé les niveaux, et la rédaction du niveau intermédiaire est ce qui déplace des paires entre celles du curateur et celles laissées non liées.
Ouvre-le dans le playground
Le lien de playground ci-dessous ouvre c428, qui a obtenu 1.10 et est allé au curateur. Il apparie Ambleside Amber Ale avec Bridge Ambleside Amber Ale - Pomegranate & Galena Hops même brasserie, même teneur en alcool. Les quatre questions l’accompagnent.
playground_link = make_playground_link(
{"entity_a": BY_ID["c428"]["entity_a"], "entity_b": BY_ID["c428"]["entity_b"]},
QUESTIONS,
models=[TYPESAFE_MODEL],
)
display(
Markdown(
f"🔗 [Open this pair + questions in the TypeSafe playground]({playground_link})"
)
)
Ouvre cette paire + les questions dans le playground TypeSafe →