Documentation

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%)
sortie

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 →