Découper une adresse en champs
Découper une adresse saisie sur une ligne en numéro, rue, complément, code postal et ville.
Pas besoin d’IA
GratuitInstantanéVos données ne sortent pas
Le verdict
N0, parce que le complément — bâtiment, escalier, résidence, appartement, CEDEX, BP — a des mots-clés en nombre fini, exactement comme le type de voie : un dictionnaire et une ancre le traitent, et le résultat se teste unitairement. N1 était recommandé pour cette seule raison, et il la paie cher : entraîné sur dix-huit adresses, il casse sur « 15 rue Victor Hugo 92100 Boulogne-Billancourt » et « 10 bd Saint-Michel », que N0 lit juste. Montez à N2 le jour où des adresses étrangères arrivent : libpostal a été entraîné sur des adresses de tous les pays habités, et c’est l’alternative de tout le monde, pas seulement de l’étranger — son prix est un modèle volumineux à charger et à tenir en mémoire. Et quel que soit le niveau, découper n’est pas vérifier : « 8 rue des Lilas, 75011 Lyon » ressort en champs propres et ne désigne aucun lieu. C’est le service de géocodage de la Géoplateforme qui répond à cette question-là, gratuitement et contre le référentiel national ; c’est l’étape qui suit le découpage, et elle fait sortir l’adresse de votre infrastructure.
Ancrage sur le code postal, dictionnaire de types de voie et dictionnaire de compléments
Pas besoin d’IA
Règle et algorithme classiqueN0GratuitInstantané
Python
"""
Découper une adresse postale en champs : des expressions régulières ancrées
sur le code postal.
Niveau N0. Déterministe, bibliothèque standard seule.
Deux choses font que ça marche.
D'abord, le code postal sert d'ancre. Cinq chiffres d'affilée coupent une
adresse française en deux : ce qui précède est la ligne de voie, ce qui suit
est la ville. Vouloir reconnaître la ville à son nom obligerait à embarquer
une liste des communes et à la tenir à jour.
Ensuite, le type de voie et le complément sont lus dans des dictionnaires
plutôt que devinés, pour que les abréviations que les gens tapent vraiment —
« av. », « bd », « bât », « appt » — ressortent sous une seule orthographe
canonique. Le complément est la part que la norme postale met sur des lignes
à part, et qu'un formulaire d'une ligne reçoit mêlée à la voie ; ses
mots-clés sont en nombre fini, et c'est pourquoi un dictionnaire suffit.
Ce que cela ne fait pas, c'est vérifier que l'adresse existe. Découper sans
vérifier donne des champs propres qui peuvent être faux : « 8 rue des Lilas,
75011 Lyon » se découpe parfaitement et ne désigne aucun lieu. C'est le
service de géocodage national qui répond à cette question, et c'est une
étape à part.
"""
import re
import unicodedata
# The street types of a French address, with the abbreviations people type.
# This is trade knowledge, not example data, so it belongs in the snippet.
STREET_TYPES = {
"r": "rue", "rue": "rue",
"av": "avenue", "ave": "avenue", "avenue": "avenue",
"bd": "boulevard", "bld": "boulevard", "boul": "boulevard", "boulevard": "boulevard",
"imp": "impasse", "impasse": "impasse",
"all": "allée", "allee": "allée",
"ch": "chemin", "chemin": "chemin",
"pl": "place", "place": "place",
"rte": "route", "route": "route",
"quai": "quai", "cours": "cours", "crs": "cours",
"sq": "square", "square": "square",
"voie": "voie", "passage": "passage", "sentier": "sentier",
"chaussee": "chaussée", "fbg": "faubourg", "faubourg": "faubourg",
"cite": "cité", "villa": "villa", "esplanade": "esplanade",
}
# The complement keywords of a French address, with the abbreviations people
# type. Trade knowledge again, and a closed list: the postal standard gives
# each of these a line of its own, and a one-line form gets them mixed into the
# street.
COMPLEMENTS = {
"bat": "bâtiment", "batt": "bâtiment", "batiment": "bâtiment", "immeuble": "immeuble",
"esc": "escalier", "escalier": "escalier",
"app": "appartement", "apt": "appartement", "appt": "appartement", "appartement": "appartement",
"etage": "étage", "porte": "porte", "hall": "hall", "entree": "entrée",
"res": "résidence", "residence": "résidence", "lotissement": "lotissement",
"lieu-dit": "lieu-dit", "lieudit": "lieu-dit",
"bp": "BP", "cs": "CS", "tsa": "TSA", "cedex": "CEDEX", "chez": "chez",
}
FIELDS = ("number", "street_type", "street", "complement", "postcode", "city")
# A house number or a range of them, and the repetition index that may follow:
# 8, 8-10, 8 bis, 12B. A lone letter counts only when it touches the number, so
# the "r" of "8 r des Lilas" stays a street type.
HOUSE_NUMBER = re.compile(r"^(\d{1,4}(?:-\d{1,4})?)(?:\s*(bis|ter|quater)\b|([a-z])\b)?", re.IGNORECASE)
# A French postcode: five digits standing alone.
POSTCODE = re.compile(r"\b\d{5}\b")
def normalise(text: str) -> str:
"""Reduce commas, line breaks, exotic spaces and byte order marks to a single plain space."""
text = unicodedata.normalize("NFKC", text).replace(",", " ")
return re.sub(r"[\s\ufeff]+", " ", text).strip()
def fold(word: str) -> str:
"""Lowercase, drop the accents and the trailing dot, for lookup only."""
decomposed = unicodedata.normalize("NFD", word.lower())
return "".join(c for c in decomposed if not unicodedata.combining(c)).rstrip(".")
def _is_designator(word: str) -> bool:
"""What follows a complement keyword: a number, a single letter, another keyword."""
return word.isdigit() or (len(word) == 1 and word.isalpha()) or fold(word) in COMPLEMENTS
def _cut_complement(words: list[str]) -> tuple[list[str], list[str]]:
"""
Take the complement out of the street line, and return the two parts.
Mid-line, a complement keyword opens a complement that runs to the end of
the line — "8 rue des Lilas Bât C Apt 12" — but only when it is followed by
what a complement is followed by: a number, a single letter, another
keyword. Without that condition "rue de la Porte Maillot" would lose half
its name to the word "Porte".
At the start of the line, the complement closes where the street opens: the
first street type, and the house number in front of it if there is one.
"Résidence du Parc 3 rue de la Paix" cuts before the 3. With no street type
at all, the whole line is the complement: "Lieu-dit Les Granges".
"""
opening = next(
(i for i, word in enumerate(words)
if fold(word) in COMPLEMENTS and (i == 0 or i == len(words) - 1 or _is_designator(words[i + 1]))),
None,
)
if opening is None:
return words, []
if opening > 0:
return words[:opening], words[opening:]
street = next((i for i, word in enumerate(words[1:], 1) if fold(word) in STREET_TYPES), None)
if street is None:
return [], words
closing = street - 1 if street > 1 and HOUSE_NUMBER.fullmatch(words[street - 1]) else street
return words[closing:], words[:closing]
def parse(address: str) -> dict:
"""
Split an address into number, street type, street, complement, postcode and
town.
Every field is a string, empty when the address does not carry it. Returning
an empty string rather than nothing at all keeps the caller from having to
test each field before printing it.
"""
fields = dict.fromkeys(FIELDS, "")
text = normalise(address)
# The anchor. Take the last run of five digits: in a French address the
# postcode and the town close the address, so a five-digit number earlier in
# the line is not taken for the postcode.
postcodes = list(POSTCODE.finditer(text))
if postcodes:
found = postcodes[-1]
fields["postcode"] = found.group()
fields["city"] = text[found.end():].strip()
text = text[: found.start()].strip()
words, complement = _cut_complement(text.split())
fields["complement"] = " ".join(complement)
text = " ".join(words)
number = HOUSE_NUMBER.match(text)
if number:
fields["number"] = " ".join(part for part in number.groups() if part)
text = text[number.end():].strip()
words = text.split()
if words:
canonical = STREET_TYPES.get(fold(words[0]))
if canonical:
# Rewrite the abbreviation, so two spellings of one street compare
# equal downstream.
fields["street_type"] = canonical
words[0] = canonical
fields["street"] = " ".join(words)
return fieldsJavaScript
/**
* Découper une adresse postale en champs : des expressions régulières ancrées
* sur le code postal.
*
* Niveau N0. Déterministe, bibliothèque standard seule.
*
* Deux choses font que ça marche.
*
* D'abord, le code postal sert d'ancre. Cinq chiffres d'affilée coupent une
* adresse française en deux : ce qui précède est la ligne de voie, ce qui suit
* est la ville. Vouloir reconnaître la ville à son nom obligerait à embarquer
* une liste des communes et à la tenir à jour.
*
* Ensuite, le type de voie et le complément sont lus dans des dictionnaires
* plutôt que devinés, pour que les abréviations que les gens tapent vraiment —
* « av. », « bd », « bât », « appt » — ressortent sous une seule orthographe
* canonique. Le complément est la part que la norme postale met sur des lignes
* à part, et qu'un formulaire d'une ligne reçoit mêlée à la voie ; ses
* mots-clés sont en nombre fini, et c'est pourquoi un dictionnaire suffit.
*
* Ce que cela ne fait pas, c'est vérifier que l'adresse existe. Découper sans
* vérifier donne des champs propres qui peuvent être faux : « 8 rue des Lilas,
* 75011 Lyon » se découpe parfaitement et ne désigne aucun lieu. C'est le
* service de géocodage national qui répond à cette question, et c'est une
* étape à part.
*/
// The street types of a French address, with the abbreviations people type.
// This is trade knowledge, not example data, so it belongs in the snippet.
export const STREET_TYPES = {
r: 'rue', rue: 'rue',
av: 'avenue', ave: 'avenue', avenue: 'avenue',
bd: 'boulevard', bld: 'boulevard', boul: 'boulevard', boulevard: 'boulevard',
imp: 'impasse', impasse: 'impasse',
all: 'allée', allee: 'allée',
ch: 'chemin', chemin: 'chemin',
pl: 'place', place: 'place',
rte: 'route', route: 'route',
quai: 'quai', cours: 'cours', crs: 'cours',
sq: 'square', square: 'square',
voie: 'voie', passage: 'passage', sentier: 'sentier',
chaussee: 'chaussée', fbg: 'faubourg', faubourg: 'faubourg',
cite: 'cité', villa: 'villa', esplanade: 'esplanade',
};
// The complement keywords of a French address, with the abbreviations people
// type. Trade knowledge again, and a closed list: the postal standard gives
// each of these a line of its own, and a one-line form gets them mixed into the
// street.
export const COMPLEMENTS = {
bat: 'bâtiment', batt: 'bâtiment', batiment: 'bâtiment', immeuble: 'immeuble',
esc: 'escalier', escalier: 'escalier',
app: 'appartement', apt: 'appartement', appt: 'appartement', appartement: 'appartement',
etage: 'étage', porte: 'porte', hall: 'hall', entree: 'entrée',
res: 'résidence', residence: 'résidence', lotissement: 'lotissement',
'lieu-dit': 'lieu-dit', lieudit: 'lieu-dit',
bp: 'BP', cs: 'CS', tsa: 'TSA', cedex: 'CEDEX', chez: 'chez',
};
export const FIELDS = ['number', 'street_type', 'street', 'complement', 'postcode', 'city'];
// A house number or a range of them, and the repetition index that may follow:
// 8, 8-10, 8 bis, 12B. A lone letter counts only when it touches the number, so
// the "r" of "8 r des Lilas" stays a street type.
const HOUSE_NUMBER = /^(\d{1,4}(?:-\d{1,4})?)(?:\s*(bis|ter|quater)\b|([a-z])\b)?/i;
// A French postcode: five digits standing alone.
const POSTCODE = /\b\d{5}\b/g;
/** Reduce commas, line breaks, exotic spaces and byte order marks to a single plain space. */
export function normalise(text) {
return text.normalize('NFKC').replaceAll(',', ' ').replace(/\s+/g, ' ').trim();
}
/** Lowercase, drop the accents and the trailing dot, for lookup only. */
export function fold(word) {
return word.toLowerCase().normalize('NFD').replace(/\p{M}/gu, '').replace(/\.+$/, '');
}
/** What follows a complement keyword: a number, a single letter, another keyword. */
function isDesignator(word) {
return /^\d+$/.test(word) || /^\p{L}$/u.test(word) || fold(word) in COMPLEMENTS;
}
/**
* Take the complement out of the street line, and return the two parts.
*
* Mid-line, a complement keyword opens a complement that runs to the end of the
* line — "8 rue des Lilas Bât C Apt 12" — but only when it is followed by what a
* complement is followed by: a number, a single letter, another keyword.
* Without that condition "rue de la Porte Maillot" would lose half its name to
* the word "Porte".
*
* At the start of the line, the complement closes where the street opens: the
* first street type, and the house number in front of it if there is one.
* "Résidence du Parc 3 rue de la Paix" cuts before the 3. With no street type at
* all, the whole line is the complement: "Lieu-dit Les Granges".
*/
function cutComplement(words) {
const opening = words.findIndex((word, i) => fold(word) in COMPLEMENTS
&& (i === 0 || i === words.length - 1 || isDesignator(words[i + 1])));
if (opening === -1) return [words, []];
if (opening > 0) return [words.slice(0, opening), words.slice(opening)];
const street = words.findIndex((word, i) => i > 0 && fold(word) in STREET_TYPES);
if (street === -1) return [[], words];
const before = words[street - 1];
const closing = street > 1 && /^\d{1,4}(?:-\d{1,4})?$/.test(before) ? street - 1 : street;
return [words.slice(closing), words.slice(0, closing)];
}
/**
* Split an address into number, street type, street, complement, postcode and
* town.
*
* Every field is a string, empty when the address does not carry it. Returning
* an empty string rather than nothing at all keeps the caller from having to
* test each field before printing it.
*/
export function parse(address) {
const fields = Object.fromEntries(FIELDS.map((name) => [name, '']));
let text = normalise(address);
// The anchor. Take the last run of five digits: in a French address the
// postcode and the town close the address, so a five-digit number earlier in
// the line is not taken for the postcode.
const postcodes = [...text.matchAll(POSTCODE)];
if (postcodes.length > 0) {
const found = postcodes.at(-1);
fields.postcode = found[0];
fields.city = text.slice(found.index + found[0].length).trim();
text = text.slice(0, found.index).trim();
}
const [line, complement] = cutComplement(text.split(' ').filter(Boolean));
fields.complement = complement.join(' ');
text = line.join(' ');
const number = HOUSE_NUMBER.exec(text);
if (number) {
fields.number = number.slice(1).filter(Boolean).join(' ');
text = text.slice(number[0].length).trim();
}
const words = text.split(' ').filter(Boolean);
if (words.length > 0) {
const canonical = STREET_TYPES[fold(words[0])];
if (canonical) {
// Rewrite the abbreviation, so two spellings of one street compare equal
// downstream.
fields.street_type = canonical;
words[0] = canonical;
}
fields.street = words.join(' ');
}
return fields;
}Essayer
interactifLe code ci-dessus tourne dans votre navigateur. Écrivez dans le champ : le résultat se refait sous vos yeux.
Le découpage se fait dans votre navigateur, sans modèle ni appel : le code postal coupe la ligne, un dictionnaire de types de voie et un dictionnaire de compléments font le reste. Aucune adresse ne part ailleurs.
Ou partez d’un exemple
La saisie libre et les autres exemples ont besoin de JavaScript. L’exemple affiché ci-dessous, lui, a été calculé à la construction du site.
4 passages attrapés
Ce que le code a repéré
15 rue Victor Hugo 92100 Boulogne-Billancourt
Ce qu’il renvoie
| Champ | Ce que l’extrait y met |
|---|---|
| Numéro | 15 |
| Voie | rue Victor Hugo |
| Complément | — rien |
| Code postal | 92100 |
| Ville | Boulogne-Billancourt |
Découpage seul : rien ici ne vérifie que l’adresse existe.
Là, ça casseVoir le niveau au-dessus
Ce que ça implique
- Vos données ne sortent pas
- Résultat toujours identique
- Se teste unitairement, cas par cas
- Aucun fournisseur dans la boucle
- Empreinte négligeable
Ce que cela ajoute à votre périmètre réglementaire
- Aucun périmètre spécifique ajouté : l’adresse ne quitte pas votre infrastructure
Point de rupture
Hors de France, l’ancre tombe. « Hauptstrasse 5, 10115 Berlin » laisse le numéro dans la rue, puisqu’il s’écrit derrière ; « 42 Rowan Street, Bristol BS1 4TQ », sans suite de cinq chiffres, ressort sans code postal ni ville. Et même en France, le découpage ne vérifie rien : « 8 rue des Lilas, 75011 Lyon » ressort en cinq champs propres, avec un code postal parisien sur une ville qui n’est pas la sienne.
Quand passer au niveau suivant
Des adresses étrangères arrivent, ou le mot qui ouvre un complément n’est pas dans le dictionnaire, et vous ajoutez une exception par semaine.
Les quatre options
- RecommandéN0Ancrage sur le code postal, dictionnaire de types de voie et dictionnaire de complémentsGratuit · Instantané
- N1Étiquetage des jetons par régression logistique sur le contextenégligeable · Instantané
- N2Analyseur d’adresses statistique auto-hébergé (libpostal), l’alternative de tout le mondePeu coûteux · Instantané
- N3Découpage structuré par appel à un modèle généralisteélevé · Une seconde
Les autres options
N1Étiquetage des jetons par régression logistique sur le contextenégligeable · Instantané
Étiquetage des jetons par régression logistique sur le contexte
Modèle classique légerN1négligeableInstantané
Python
"""
Étiqueter chaque jeton d'une adresse par une régression logistique sur des
traits de contexte.
Niveau N1. N0 lit l'adresse qu'il attend ; celui-ci lit l'adresse qu'on lui
donne. Chaque mot est étiqueté pour lui-même — numéro, type de voie, nom de
voie, complément, code postal, ville — d'après ce à quoi il ressemble et
d'après ce qui se trouve de part et d'autre. Les champs sont ensuite
reconstruits à partir des étiquettes.
C'est là tout le gain : un complément au milieu de la ligne n'avale plus la
voie, parce que « Bâtiment » est un mot que le modèle a déjà vu à cette place,
et non un jeton imprévu dans un motif figé.
Le prix est un jeu d'entraînement étiqueté. Le test et l'essai l'entraînent
sur dix-huit adresses étiquetées à la main, et il lit déjà une rue, une
résidence et une ville qu'il n'a jamais vues ; toute convention absente de ce
jeu est une convention que le modèle ne connaît pas.
"""
import re
import unicodedata
from sklearn.feature_extraction import DictVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import make_pipeline
# The labels are the fields, so the mapping back is a grouping and nothing more.
LABELS = ("number", "street_type", "street", "complement", "postcode", "city")
TOKEN = re.compile(r"[^\W_]+")
def tokenise(address: str) -> list[str]:
"""Words and numbers, punctuation dropped."""
return TOKEN.findall(unicodedata.normalize("NFKC", address))
def fold(word: str) -> str:
"""Lowercase and accent-free, so "Allée" and "allee" share a trait."""
decomposed = unicodedata.normalize("NFD", word.lower())
return "".join(c for c in decomposed if not unicodedata.combining(c))
def features(tokens: list[str], i: int) -> dict:
"""
What the token looks like, and what surrounds it.
The neighbours carry much of the signal: five digits followed by a
capitalised word look like a postcode and a town. Position counts as well:
on "75011 Paris, 8 rue des Lilas" the end of the line wins, and the town
comes out as "Lilas".
"""
token = tokens[i]
return {
f"token={fold(token)}": 1,
f"previous={fold(tokens[i - 1]) if i else '<start>'}": 1,
f"next={fold(tokens[i + 1]) if i + 1 < len(tokens) else '<end>'}": 1,
"digits": float(token.isdigit()),
"five_digits": float(token.isdigit() and len(token) == 5),
"short_number": float(token.isdigit() and len(token) <= 3),
"capitalised": float(token[:1].isupper()),
"position": i / len(tokens),
"last": float(i == len(tokens) - 1),
}
def train(examples):
"""
`examples` pairs an address with one label per token, in reading order.
Tagging is the work of this rung, and mistagging it is the usual way the
rung is made to fail, so a misaligned example is rejected rather than
quietly learnt.
"""
rows, targets = [], []
for address, labels in examples:
tokens = tokenise(address)
if len(tokens) != len(labels):
raise ValueError(f"{len(tokens)} tokens for {len(labels)} labels: {address!r}")
rows += [features(tokens, i) for i in range(len(tokens))]
targets += list(labels)
model = make_pipeline(
DictVectorizer(),
LogisticRegression(max_iter=1000, class_weight="balanced"),
)
model.fit(rows, targets)
return model
def parse(model, address: str) -> dict:
"""Group the labelled tokens back into fields, in reading order."""
fields = dict.fromkeys(LABELS, "")
tokens = tokenise(address)
if not tokens:
return fields
predicted = model.predict([features(tokens, i) for i in range(len(tokens))])
for token, label in zip(tokens, predicted):
fields[label] = f"{fields[label]} {token}".strip()
# The street type stays available on its own, and also opens the street, so
# the output can be compared field by field with the rung below.
fields["street"] = f"{fields['street_type']} {fields['street']}".strip()
return fieldsJavaScript
/**
* Étiqueter chaque jeton d'une adresse par une régression logistique sur des
* traits de contexte.
*
* Niveau N1. N0 lit l'adresse qu'il attend ; celui-ci lit l'adresse qu'on lui
* donne. Chaque mot est étiqueté pour lui-même — numéro, type de voie, nom de
* voie, complément, code postal, ville — d'après ce à quoi il ressemble et
* d'après ce qui se trouve de part et d'autre. Les champs sont ensuite
* reconstruits à partir des étiquettes.
*
* C'est là tout le gain : un complément au milieu de la ligne n'avale plus la
* voie, parce que « Bâtiment » est un mot que le modèle a déjà vu à cette place,
* et non un jeton imprévu dans un motif figé.
*
* Une régression binaire par étiquette, entraînée par simple descente de
* gradient, et la plus forte l'emporte. Écrite ici plutôt que tirée d'une
* bibliothèque : aucune dépendance à installer pour un modèle de cette taille.
*/
// The labels are the fields, so the mapping back is a grouping and nothing more.
export const LABELS = ['number', 'street_type', 'street', 'complement', 'postcode', 'city'];
const TOKEN = /[\p{L}\p{N}]+/gu;
/** Words and numbers, punctuation dropped. */
export function tokenise(address) {
return address.normalize('NFKC').match(TOKEN) ?? [];
}
/** Lowercase and accent-free, so "Allée" and "allee" share a trait. */
export function fold(word) {
return word.toLowerCase().normalize('NFD').replace(/\p{M}/gu, '');
}
const isDigits = (token) => /^\d+$/.test(token);
/**
* What the token looks like, and what surrounds it.
*
* The neighbours carry much of the signal: five digits followed by a
* capitalised word look like a postcode and a town. Position counts as well:
* on "75011 Paris, 8 rue des Lilas" the end of the line wins, and the town
* comes out as "Lilas".
*/
export function features(tokens, i) {
const token = tokens[i];
return {
[`token=${fold(token)}`]: 1,
[`previous=${i > 0 ? fold(tokens[i - 1]) : '<start>'}`]: 1,
[`next=${i + 1 < tokens.length ? fold(tokens[i + 1]) : '<end>'}`]: 1,
digits: Number(isDigits(token)),
five_digits: Number(isDigits(token) && token.length === 5),
short_number: Number(isDigits(token) && token.length <= 3),
capitalised: Number(token[0] !== token[0].toLowerCase()),
position: i / tokens.length,
last: Number(i === tokens.length - 1),
};
}
/**
* `examples` pairs an address with one label per token, in reading order.
*
* Tagging is the work of this rung, and mistagging it is the usual way the rung
* is made to fail, so a misaligned example is rejected rather than quietly
* learnt.
*/
export function train(examples, { epochs = 300, rate = 0.3 } = {}) {
const rows = [];
const targets = [];
for (const [address, labels] of examples) {
const tokens = tokenise(address);
if (tokens.length !== labels.length) {
throw new Error(`${tokens.length} tokens for ${labels.length} labels: ${address}`);
}
tokens.forEach((_, i) => {
rows.push(features(tokens, i));
targets.push(labels[i]);
});
}
// Two labels at least, as scikit-learn demands: with one, every token would
// get it, and with none, parsing would have no label to pick.
if (new Set(targets).size < 2) throw new RangeError('train needs at least two different labels');
// One column per trait seen in training. A trait absent from this map is a
// word the model never met, and it simply contributes nothing at prediction.
const columns = new Map();
const vectors = rows.map((row) =>
Object.entries(row).map(([name, value]) => {
if (!columns.has(name)) columns.set(name, columns.size);
return [columns.get(name), value];
}));
const weights = {};
for (const label of new Set(targets)) {
const w = new Float64Array(columns.size + 1); // the last cell is the bias
for (let epoch = 0; epoch < epochs; epoch += 1) {
for (let i = 0; i < vectors.length; i += 1) {
const target = targets[i] === label ? 1 : 0;
const error = 1 / (1 + Math.exp(-score(w, vectors[i]))) - target;
for (const [column, value] of vectors[i]) w[column] -= rate * error * value;
w[columns.size] -= rate * error;
}
}
weights[label] = w;
}
return { columns, weights };
}
/** Group the labelled tokens back into fields, in reading order. */
export function parse(model, address) {
const fields = Object.fromEntries(LABELS.map((name) => [name, '']));
const tokens = tokenise(address);
tokens.forEach((token, i) => {
const vector = vectorise(features(tokens, i), model.columns);
const label = Object.keys(model.weights).reduce((best, candidate) =>
score(model.weights[candidate], vector) > score(model.weights[best], vector) ? candidate : best);
fields[label] = `${fields[label]} ${token}`.trim();
});
// The street type stays available on its own, and also opens the street, so
// the output can be compared field by field with the rung below.
fields.street = `${fields.street_type} ${fields.street}`.trim();
return fields;
}
function vectorise(row, columns) {
return Object.entries(row)
.filter(([name]) => columns.has(name))
.map(([name, value]) => [columns.get(name), value]);
}
function score(weights, vector) {
let total = weights[weights.length - 1];
for (const [column, value] of vector) total += weights[column] * value;
return total;
}Ce que ça implique
- Vos données ne sortent pas
- Résultat toujours identique
- Se mesure sur un échantillon, pas cas par cas
- Dépend d’une bibliothèque
- Empreinte faible
Ce que cela ajoute à votre périmètre réglementaire
- Traitement de données personnelles sur votre infrastructure
- Le jeu d’entraînement est étiqueté à la main : constitué d’adresses réelles, il entre dans votre registre des traitements si vous en tenez un
Point de rupture
Le modèle régresse sur les adresses françaises les plus ordinaires, que N0 lit juste. Entraîné sur les dix-huit adresses du test : « 15 rue Victor Hugo 92100 Boulogne-Billancourt » rend la voie « rue », le complément « Victor Hugo Boulogne » et la ville « Billancourt » ; « 10 bd Saint-Michel » rend la voie « bd » ; « Lieu-dit Les Granges 24200 Sarlat-la-Canéda » rend la ville « Canéda ». Une rue au nom d’une personne, une ville à trait d’union, une abréviation : ce n’est pas une marge. Et il ne s’abstient jamais : sur « Hauptstrasse 5, 10115 Berlin », il étiquette quand même.
Quand passer au niveau suivant
Vous relisez les sorties et vous voyez des noms de rue coupés là où le modèle n’a rien vu de semblable dans son jeu d’étiquetage.
N2Analyseur d’adresses statistique auto-hébergé (libpostal), l’alternative de tout le mondePeu coûteux · Instantané
Analyseur d’adresses statistique auto-hébergé (libpostal), l’alternative de tout le monde
Petit modèle spécialisé auto-hébergéN2Peu coûteuxInstantané
Python
"""
Découper une adresse avec un analyseur statistique auto-hébergé.
Niveau N2. libpostal est un analyseur entraîné sur plus d'un milliard
d'adresses de tous les pays habités. Il étiquette un jeton d'après son
contexte, comme au niveau N1, mais là où N1 connaît les dix-huit adresses
françaises qu'on lui a étiquetées, l'entraînement de libpostal couvre aussi
les conventions des autres pays. S'il en lit une correctement, rien ici ne le
mesure. Il tourne sur votre machine, donc aucune adresse n'en sort.
Ce qui vous appartient à ce niveau n'est pas le modèle. C'est le lot, la borne
de taille, la correspondance entre son jeu d'étiquettes et le vôtre, et la
réponse à « que fait le code quand l'analyseur renvoie quelque chose
d'inutilisable ». Le modèle est une boîte noire, avec une liste d'étiquettes
fixe et aucun score de confiance, et la dernière fonction ci-dessous est
l'endroit où cela devient votre problème.
"""
from __future__ import annotations
# A postal address is short: La Poste's rules allow six lines of 38 characters,
# which is 228 characters plus the separators. The cap is set above that, at
# 300, so that a line written with generous punctuation still goes through;
# anything past it is not an address, and is refused before parsing.
MAX_CHARACTERS = 300
FIELDS = ("number", "street", "complement", "postcode", "city")
# libpostal's label set is its own, and wider than ours. Five of its labels
# land in our complement, "house" among them: libpostal's name for a building
# or a venue, where a French residence name goes. Everything unmapped is dropped
# on purpose: an unmapped label that silently became a field would be a
# surprise in a letter.
COMPONENTS = {
"house_number": "number",
"road": "street",
"house": "complement",
"unit": "complement",
"level": "complement",
"staircase": "complement",
"entrance": "complement",
"postcode": "postcode",
"city": "city",
}
class ParsingUnavailable(Exception):
"""The parser failed, or answered something no caller can act on."""
class LibpostalParser:
"""The real parser: a C library and its data files, loaded once per process on import."""
def __init__(self) -> None:
from postal.parser import parse_address # a large local install
self._parse_address = parse_address
def predict(self, addresses: list[str]) -> list[dict]:
"""One label-to-value mapping per address, in the order given."""
return [self._merge(self._parse_address(a)) for a in addresses]
@staticmethod
def _merge(components) -> dict:
"""libpostal yields (value, label) pairs, and repeats a label freely."""
merged: dict[str, str] = {}
for value, label in components:
merged[label] = f"{merged.get(label, '')} {value}".strip()
return merged
def parse_addresses(addresses, parser=None, *, attempts: int = 2) -> list[dict]:
"""
Parse a batch of addresses into fields.
`parser` is injected so this can be tested without installing the model. In
production it defaults to the real one above.
The whole batch goes to the parser object in one call, but libpostal has no
batching: `LibpostalParser.predict` parses the addresses one after the other.
"""
batch = list(addresses)
for address in batch:
if len(address) > MAX_CHARACTERS:
raise ValueError(f"address longer than {MAX_CHARACTERS} characters")
if not batch:
return []
parser = parser or LibpostalParser()
rows = _predict(parser, batch, attempts)
if len(rows) != len(batch):
raise ParsingUnavailable("the parser returned one row per address, and did not")
return [_to_fields(row) for row in rows]
def _predict(parser, batch: list[str], attempts: int) -> list[dict]:
"""
Retry a failed parse once. Loading is not retried: a parser that cannot
load its data files fails before this point, with its own error.
"""
last_error: Exception | None = None
for _ in range(attempts):
try:
return parser.predict(batch)
except Exception as error: # noqa: BLE001 - any model failure is retried
last_error = error
raise ParsingUnavailable(str(last_error))
def _to_fields(row) -> dict:
"""Keep the labels we mapped, join those that share a field, drop the rest."""
if row is None:
row = {}
if not isinstance(row, dict):
raise ParsingUnavailable(f"the parser returned a {type(row).__name__}, not a mapping")
fields = dict.fromkeys(FIELDS, "")
for label, value in row.items():
field = COMPONENTS.get(label)
if field and isinstance(value, str) and value.strip():
fields[field] = f"{fields[field]} {value.strip()}".strip()
return fieldsJavaScript
/**
* Découper une adresse avec un analyseur statistique auto-hébergé.
*
* Niveau N2. libpostal est un analyseur entraîné sur plus d'un milliard
* d'adresses de tous les pays habités. Il étiquette un jeton d'après son
* contexte, comme au niveau N1, mais là où N1 connaît les dix-huit adresses
* françaises qu'on lui a étiquetées, l'entraînement de libpostal couvre aussi
* les conventions des autres pays. S'il en lit une correctement, rien ici ne le
* mesure. Il tourne sur votre machine, donc aucune adresse n'en sort.
*
* Ce qui vous appartient à ce niveau n'est pas le modèle. C'est le lot, la borne
* de taille, la correspondance entre son jeu d'étiquettes et le vôtre, et la
* réponse à « que fait le code quand l'analyseur renvoie quelque chose
* d'inutilisable ». Le modèle est une boîte noire, avec une liste d'étiquettes
* fixe et aucun score de confiance, et la dernière fonction ci-dessous est
* l'endroit où cela devient votre problème.
*/
// A postal address is short: La Poste's rules allow six lines of 38 characters,
// which is 228 characters plus the separators. The cap is set above that, at
// 300, so that a line written with generous punctuation still goes through;
// anything past it is not an address, and is refused before parsing.
export const MAX_CHARACTERS = 300;
export const FIELDS = ['number', 'street', 'complement', 'postcode', 'city'];
// libpostal's label set is its own, and wider than ours. Five of its labels
// land in our complement, "house" among them: libpostal's name for a building
// or a venue, where a French residence name goes. Everything unmapped is dropped
// on purpose: an unmapped label that silently became a field would be a
// surprise in a letter.
const COMPONENTS = {
house_number: 'number',
road: 'street',
house: 'complement',
unit: 'complement',
level: 'complement',
staircase: 'complement',
entrance: 'complement',
postcode: 'postcode',
city: 'city',
};
export class ParsingUnavailable extends Error {}
/** The real parser: a native binding and its data files, loaded once per process on import. */
export class LibpostalParser {
static async load() {
// node-postal is CommonJS: imported from a module, its exports sit under
// `default`, and `parser` is not detected as a named export.
const { parser } = (await import('node-postal')).default;
return new LibpostalParser((address) => parser.parse_address(address));
}
constructor(parseAddress) {
this.parseAddress = parseAddress;
}
/** One label-to-value mapping per address, in the order given. */
async predict(addresses) {
return addresses.map((address) => merge(this.parseAddress(address)));
}
}
/** libpostal yields {component, value} pairs, and repeats a component freely. */
function merge(components) {
const merged = {};
for (const { component, value } of components) {
merged[component] = `${merged[component] ?? ''} ${value}`.trim();
}
return merged;
}
/**
* Parse a batch of addresses into fields.
*
* `parser` is injected so this can be tested without installing the model. In
* production it defaults to the real one above.
*
* The whole batch goes to the parser object in one call, but libpostal has no
* batching: `LibpostalParser.predict` parses the addresses one after the other.
*/
export async function parseAddresses(addresses, parser, { attempts = 2 } = {}) {
const batch = [...addresses];
for (const address of batch) {
// Code points, as Python counts them: an emoji is one character, not two.
if ([...address].length > MAX_CHARACTERS) {
throw new RangeError(`address longer than ${MAX_CHARACTERS} characters`);
}
}
if (batch.length === 0) return [];
const model = parser ?? (await LibpostalParser.load());
const rows = await predict(model, batch, attempts);
if (rows.length !== batch.length) {
throw new ParsingUnavailable('the parser returned one row per address, and did not');
}
return rows.map(toFields);
}
/**
* Retry a failed parse once. Loading is not retried: a parser that cannot load
* its data files fails before this point, with its own error.
*/
async function predict(parser, batch, attempts) {
let lastError;
for (let i = 0; i < attempts; i += 1) {
try {
return await parser.predict(batch);
} catch (error) {
lastError = error;
}
}
throw new ParsingUnavailable(String(lastError));
}
/** Keep the labels we mapped, join those that share a field, drop the rest. */
function toFields(row) {
if (row !== null && row !== undefined && (typeof row !== 'object' || Array.isArray(row))) {
throw new ParsingUnavailable('the parser returned something other than a mapping');
}
const fields = Object.fromEntries(FIELDS.map((name) => [name, '']));
for (const [label, value] of Object.entries(row ?? {})) {
const field = COMPONENTS[label];
if (field && typeof value === 'string' && value.trim()) {
fields[field] = `${fields[field]} ${value.trim()}`.trim();
}
}
return fields;
}Ce que ça implique
- Vos données restent chez vous
- Résultat toujours identique
- Se mesure sur un échantillon, pas cas par cas
- Dépend d’une bibliothèque
- Empreinte modérée
Ce que cela ajoute à votre périmètre réglementaire
- Traitement de données personnelles sur votre infrastructure
- Aucun transfert à un tiers : le modèle et ses fichiers de données sont installés sur votre machine
Point de rupture
Il découpe, il ne vérifie pas, et il coûte un modèle volumineux à charger au démarrage et à tenir en mémoire : la classe de latence affichée vaut par appel, une fois le modèle chargé, pas au premier. libpostal rend des étiquettes, ni score ni refus. Si une ligne qui n’est pas une adresse — « the meeting is at ten in room four » — revient étiquetée en numéro et en rue, ou si « 8 rue des Lilas, 75011 Lyon », qui pose un code postal parisien sur une autre ville, revient en champs propres — deux réponses que le test simule —, rien dans le code ne distingue ce résultat d’un bon.
Quand passer au niveau suivant
Ce que vous recevez n’est plus une ligne d’adresse mais du texte libre — une signature de courriel, un message où l’adresse est noyée dans une phrase — et il n’y a plus de ligne à découper.
N3Découpage structuré par appel à un modèle généralisteélevé · Une seconde
Découpage structuré par appel à un modèle généraliste
API de LLM généralisteN3élevéUne seconde
Python
"""
Découper une adresse en appelant un modèle généraliste.
Niveau N3. Il est ici pour qu'on voie ce qu'il coûte, pas parce que cette
fiche le recommande.
Notez ce que le code doit faire et que N0 ne faisait pas : borner la taille de
l'entrée, réessayer après un échec, analyser une réponse qui n'est que
probablement du JSON valide, et vérifier que les champs rendus étaient bien
dans l'adresse. Ce dernier point est propre à l'extraction : une valeur que
l'adresse ne contient pas est écartée, parce qu'un code postal plausible est
pire qu'un champ vide — rien en aval ne le mettra jamais en doute.
Cette plomberie est le vrai coût de ce niveau, et c'est la part que vos tests
doivent couvrir, parce que le modèle lui-même n'est pas testable.
"""
from __future__ import annotations
import json
import re
import unicodedata
# The provider named here is an example, not a recommendation: the reasoning
# holds for any general-purpose model API, and the client is swappable. Pass
# any object with a `complete(prompt=..., temperature=...)` method.
MODEL = "gpt-4.1-mini" # an example id: check the parameters your model accepts
FIELDS = ("number", "street", "complement", "postcode", "city")
PROMPT = (
"Split the postal address below into fields.\n"
"Answer with JSON only: an object with the keys `number`, `street`,\n"
"`complement`, `postcode` and `city`. Copy the text exactly as it is\n"
"written, and leave a key empty when the address does not carry it.\n\n"
"Address:\n{address}"
)
# An address is a short line. The cap is a cost control: the provider bills the
# tokens of the prompt as well as those of the answer.
MAX_CHARACTERS = 300
class ProviderClient:
"""The one call this snippet makes, on top of the provider's SDK."""
def __init__(self, sdk=None, model: str = MODEL):
if sdk is None: # pragma: no cover - needs a key and a network
from openai import OpenAI
sdk = OpenAI()
self.sdk, self.model = sdk, model
def complete(self, *, prompt: str, temperature: float) -> str:
response = self.sdk.chat.completions.create(
model=self.model,
messages=[{"role": "user", "content": prompt}],
temperature=temperature,
)
return response.choices[0].message.content
class ParsingUnavailable(Exception):
"""The provider could not be reached, or answered something unusable."""
def parse(address: str, client=None, *, attempts: int = 3) -> dict:
"""
Split an address into number, street, complement, postcode and town.
`client` is injected so this function can be tested without a network call.
In production it defaults to a real provider client.
"""
if len(address) > MAX_CHARACTERS:
raise ValueError(f"address longer than {MAX_CHARACTERS} characters")
client = client or ProviderClient()
answer = _ask(client, address, attempts)
source = _words(address)
taken = [False] * len(source)
values = {key: _text(answer.get(key)) for key in FIELDS}
fields = dict.fromkeys(FIELDS, "")
# Kept only if the model copied it from the address, as whole words not
# already claimed by another field. Longest first, so a postcode "12" cannot
# take its digits out of "Appartement 12". What it made up is dropped, and an empty
# field is a question a human can see.
for key in sorted(FIELDS, key=lambda k: -len(_words(values[k]))):
words = _words(values[key])
for start in range(len(source) - len(words) + 1) if words else ():
end = start + len(words)
if source[start:end] == words and not any(taken[start:end]):
taken[start:end] = [True] * len(words)
fields[key] = values[key].strip()
break
return fields
def _ask(client, address: str, attempts: int) -> dict:
last_error: Exception | None = None
for _ in range(attempts):
try:
# Temperature zero, because an address that splits differently
# between two identical calls cannot be reconciled with anything.
answer = client.complete(prompt=PROMPT.format(address=address), temperature=0)
# No content (a refusal) is as unusable as prose.
parsed = json.loads(answer) if isinstance(answer, str) else None
if isinstance(parsed, dict):
return parsed
last_error = ValueError("the model answered something that is not an object")
except Exception as error: # noqa: BLE001 - any provider failure is retried
last_error = error
raise ParsingUnavailable(str(last_error))
def _text(value) -> str:
"""A string as it came, a whole number as its digits (75011), anything else as nothing."""
if isinstance(value, (int, float)) and not isinstance(value, bool) and float(value).is_integer():
return str(int(value))
return value if isinstance(value, str) else ""
def _words(text: str) -> list[str]:
"""Case, spacing and punctuation are the model's to change; the words are not."""
return re.findall(r"[^\W_]+", unicodedata.normalize("NFKC", text).lower())JavaScript
/**
* Découper une adresse en appelant un modèle généraliste.
*
* Niveau N3. Il est ici pour qu'on voie ce qu'il coûte, pas parce que cette
* fiche le recommande.
*
* Notez ce que le code doit faire et que N0 ne faisait pas : borner la taille de
* l'entrée, réessayer après un échec, analyser une réponse qui n'est que
* probablement du JSON valide, et vérifier que les champs rendus étaient bien
* dans l'adresse. Ce dernier point est propre à l'extraction : une valeur que
* l'adresse ne contient pas est écartée, parce qu'un code postal plausible est
* pire qu'un champ vide — rien en aval ne le mettra jamais en doute.
*
* Cette plomberie est le vrai coût de ce niveau, et c'est la part que vos tests
* doivent couvrir, parce que le modèle lui-même n'est pas testable.
*/
// The provider named here is an example, not a recommendation: the reasoning
// holds for any general-purpose model API, and the client is swappable. Pass
// any object with a `complete({ prompt, temperature })` method.
export const MODEL = 'gpt-4.1-mini'; // an example id: check the parameters your model accepts
export async function providerClient(sdk, model = MODEL) {
if (!sdk) {
const { OpenAI } = await import('openai');
sdk = new OpenAI();
}
return {
async complete({ prompt, temperature }) {
const response = await sdk.chat.completions.create({
model,
messages: [{ role: 'user', content: prompt }],
temperature,
});
return response.choices[0].message.content;
},
};
}
export const FIELDS = ['number', 'street', 'complement', 'postcode', 'city'];
const PROMPT = [
'Split the postal address below into fields.',
'Answer with JSON only: an object with the keys `number`, `street`,',
'`complement`, `postcode` and `city`. Copy the text exactly as it is',
'written, and leave a key empty when the address does not carry it.',
'',
'Address:',
].join('\n');
// An address is a short line. The cap is a cost control: the provider bills the
// tokens of the prompt as well as those of the answer.
export const MAX_CHARACTERS = 300;
export class ParsingUnavailable extends Error {}
/**
* Split an address into number, street, complement, postcode and town.
*
* @param {string} address
* @param {object} options
* @param {{complete: Function}} [options.client] injected so this can be tested
* without a network call; defaults to a real provider client
* @param {number} [options.attempts]
*/
export async function parse(address, { client, attempts = 3 } = {}) {
// Code points, as Python counts them: an emoji is one character, not two.
if ([...address].length > MAX_CHARACTERS) {
throw new RangeError(`address longer than ${MAX_CHARACTERS} characters`);
}
client ??= await providerClient();
const answer = await ask(client, address, attempts);
const source = words(address);
const taken = source.map(() => false);
const values = Object.fromEntries(FIELDS.map((key) => [key, text(answer[key])]));
const fields = Object.fromEntries(FIELDS.map((name) => [name, '']));
// Kept only if the model copied it from the address, as whole words not
// already claimed by another field. Longest first, so a postcode "12" cannot
// take its digits out of "Appartement 12". What it made up is dropped, and an empty
// field is a question a human can see.
for (const key of [...FIELDS].sort((a, b) => words(values[b]).length - words(values[a]).length)) {
const wanted = words(values[key]);
for (let start = 0; wanted.length && start + wanted.length <= source.length; start += 1) {
const end = start + wanted.length;
if (wanted.every((w, i) => source[start + i] === w) && !taken.slice(start, end).some(Boolean)) {
taken.fill(true, start, end);
fields[key] = values[key].trim();
break;
}
}
}
return fields;
}
async function ask(client, address, attempts) {
let lastError;
for (let i = 0; i < attempts; i += 1) {
try {
const answer = await client.complete({
prompt: `${PROMPT}\n${address}`,
// Temperature zero, because an address that splits differently between
// two identical calls cannot be reconciled with anything.
temperature: 0,
});
// No content (a refusal) is as unusable as prose.
const parsed = typeof answer === 'string' ? JSON.parse(answer) : null;
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
lastError = new Error('the model answered something that is not an object');
} catch (error) {
lastError = error;
}
}
throw new ParsingUnavailable(String(lastError));
}
/** A string as it came, a whole number as its digits (75011), anything else as nothing. */
function text(value) {
if (Number.isInteger(value)) return String(value);
return typeof value === 'string' ? value : '';
}
/** Case, spacing and punctuation are the model's to change; the words are not. */
function words(value) {
return value.normalize('NFKC').toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? [];
}Ce que ça implique
- Vos données partent chez un tiers
- Résultat variable d’un appel à l’autre
- Difficile à tester
- Dépend d’un fournisseur externe
- Empreinte élevée
Ce que cela ajoute à votre périmètre réglementaire
- Transfert de données personnelles à un sous-traitant, avec l’encadrement contractuel que cela suppose
- Localisation du traitement à vérifier auprès du fournisseur
- Ne vous dispense pas de vos propres obligations d’information et de minimisation
Point de rupture
La garde de l’extrait vérifie d’où vient une valeur, pas si elle est à sa place. Si, sur « 12 rue de Lille, 59000 Lille », le modèle intervertit la rue et la ville — le test simule cette réponse —, les deux valeurs ont bien été copiées de l’adresse, les deux passent le contrôle, et la réponse revient proprement structurée et fausse.
Quand passer au niveau suivant
Il n’y a pas de niveau au-dessus.
Pour aller plus loin
- libpostal — analyseur d’adresses international, entraîné sur données ouvertes
- Géoplateforme — service de géocodage des adresses françaises, qui remplace l’API de la Base Adresse Nationale
- Michael Tandy — Falsehoods programmers believe about addresses
- OpenStreetMap — clé addr, le vocabulaire des composants d’adresse
Source des chiffres
- Géoplateforme, guide du géocodage (consulté) : point d’accès https://data.geopf.fr/geocodage/search, service public gratuit, usage limité à cinquante requêtes par seconde depuis une même adresse IP ; l’API Adresse de la Base Adresse Nationale y est intégrée
- La Poste, SP 8855 volume 2 — adressage des plis : l’adresse d’un particulier en six lignes, code postal et localité en dernière ligne, 38 caractères par ligne
- libpostal — README : entraîné sur plus d’un milliard d’adresses de tous les pays habités, étiquettes de l’analyseur
- Yassine et al., Deepparse (2023) — temps moyen d’analyse par adresse de libpostal, et absence de traitement par lots
- OpenAI — tarifs de l’API, prix par million de jetons en entrée et en sortie
Métadonnées
Information générale, tenue à jour au mieux. Ce n’est pas un avis juridique. Faites valider votre situation par un professionnel.