companies: Tippfehler und Zusätze im Trockenlauf als dieselbe Firma erkennen
Die Live-Läufe vom 11.09. mit Schwellwert 2, 3 und 4 haben zwei Schwächen der
Klassifizierung gezeigt, die vor 1a.2 behoben sein müssen — dort wird sie
dauerhaft in die Datenbank geschrieben.
Erstens stand JS Research mit 204 Firmen an zweiter Stelle der Agenturliste:
eine einzelne Firma, deren Name siebzehnmal leicht anders geschrieben war
("JS Research", "S Research UG", ...). Ebenso Dr. Müller / Dr. Mueller /
Dr. D. Müller und die Praxis Prof. Dhom mit Datumszusätzen im Namen. Ein
Tippfehler ist kein zweiter Kunde. Normalisierte Namen werden jetzt zu
Ähnlichkeits-Clustern zusammengefasst: geringe Levenshtein-Distanz oder ein
gemeinsamer Anfang aus drei Wörtern gilt unter EINER Firmen-E-Mail als dieselbe
Einheit. Echte Agenturen mit verschiedenen Kundennamen bleiben davon unberührt.
Zweitens galt Welcon Europe (Ltd. & Co. KG neben GmbH & Co. KG) als Agentur,
weil die Rechtsformen erst nach dem Entfernen der Satzzeichen abgeglichen
wurden — "gmbh & co kg" passte dann nie auf "gmbh co kg". Rechtsformen werden
jetzt als einzelne Wörter vom Ende her gestrippt, bis keines mehr passt; das
deckt alle Kombinationen ab, ohne sie einzeln aufzuführen.
Auf dem Bestand steigen die automatisch zusammenführbaren Namensvarianten
damit von 723 auf 1603 Adressen, die Sammeladressen sinken von 1425 auf 1082 —
was jetzt dort steht, sind durchweg Agenturen oder Mehrkunden-Absender.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e536e52cdb
commit
5d0570bee0
2 changed files with 129 additions and 14 deletions
|
|
@ -48,14 +48,33 @@ class CompanyMergeReport
|
||||||
* „Hoppe Unternehmensberatung" und „Hoppe Unternehmensberatung GmbH" sind
|
* „Hoppe Unternehmensberatung" und „Hoppe Unternehmensberatung GmbH" sind
|
||||||
* dieselbe Firma.
|
* dieselbe Firma.
|
||||||
*
|
*
|
||||||
|
* Als einzelne Wörter geführt und vom Ende her wiederholt entfernt, bis
|
||||||
|
* keines mehr passt. So fallen „GmbH & Co. KG", „Ltd. & Co. KG" und
|
||||||
|
* „AG & Co. KGaA" gleichermaßen weg, ohne dass jede Kombination einzeln
|
||||||
|
* aufgeführt sein muss — die Satzzeichen sind zu dem Zeitpunkt ohnehin
|
||||||
|
* schon fort.
|
||||||
|
*
|
||||||
* @var list<string>
|
* @var list<string>
|
||||||
*/
|
*/
|
||||||
private const LegalForms = [
|
private const LegalFormTokens = [
|
||||||
'gmbh & co kg', 'gmbh & co. kg', 'gmbh und co kg', 'ag & co kg',
|
'gmbh', 'mbh', 'ug', 'haftungsbeschraenkt', 'ohg', 'kgaa', 'kg', 'ag', 'se', 'co', 'cie', 'und',
|
||||||
'gmbh', 'mbh', 'ug haftungsbeschraenkt', 'ug', 'ohg', 'kgaa', 'kg', 'ag', 'se',
|
'ek', 'ev', 'gbr', 'partg', 'ltd', 'llc', 'inc', 'bv', 'nv', 'sa', 'srl', 'plc', 'mvz',
|
||||||
'e k', 'ek', 'ev', 'e v', 'gbr', 'partg', 'ltd', 'llc', 'inc', 'bv', 'nv', 'sa', 'srl', 'plc',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zwei normalisierte Namen gelten als derselbe, wenn sie sich nur um
|
||||||
|
* wenige Zeichen unterscheiden (Tippfehler: „Berursverband" /
|
||||||
|
* „Berufsverband", „S Research" / „JS Research") oder denselben Anfang
|
||||||
|
* teilen (Zusätze: „Praxis Prof Dr Dhom Kollegen" / „Praxis Prof Dr Dhom
|
||||||
|
* Partner"). Unter EINER Firmen-E-Mail ist ein gemeinsamer Anfang aus
|
||||||
|
* drei Wörtern ein sehr starkes Zeichen für dieselbe Einheit.
|
||||||
|
*/
|
||||||
|
private const SimilarMaxDistance = 2;
|
||||||
|
|
||||||
|
private const SimilarDistanceRatio = 0.15;
|
||||||
|
|
||||||
|
private const SharedPrefixWords = 3;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param int $collectionThreshold ab wie vielen abweichenden Namen eine Adresse als Sammeladresse gilt (Entscheidung E1)
|
* @param int $collectionThreshold ab wie vielen abweichenden Namen eine Adresse als Sammeladresse gilt (Entscheidung E1)
|
||||||
* @return array{
|
* @return array{
|
||||||
|
|
@ -188,7 +207,7 @@ class CompanyMergeReport
|
||||||
}
|
}
|
||||||
|
|
||||||
$group['namen'] = count($group['beispiele']);
|
$group['namen'] = count($group['beispiele']);
|
||||||
$group['namen_normalisiert'] = count($group['normalisiert']);
|
$group['namen_normalisiert'] = $this->countDistinct(array_keys($group['normalisiert']));
|
||||||
unset($group['normalisiert']);
|
unset($group['normalisiert']);
|
||||||
|
|
||||||
$result[] = $group;
|
$result[] = $group;
|
||||||
|
|
@ -232,12 +251,60 @@ class CompanyMergeReport
|
||||||
$value = (string) preg_replace('/[^a-z0-9]+/', ' ', $value);
|
$value = (string) preg_replace('/[^a-z0-9]+/', ' ', $value);
|
||||||
$value = trim((string) preg_replace('/\s+/', ' ', $value));
|
$value = trim((string) preg_replace('/\s+/', ' ', $value));
|
||||||
|
|
||||||
foreach (self::LegalForms as $form) {
|
$words = explode(' ', $value);
|
||||||
if (str_ends_with($value, ' '.$form)) {
|
|
||||||
$value = trim(mb_substr($value, 0, -mb_strlen($form) - 1));
|
// Vom Ende her, solange das letzte Wort eine Rechtsform ist — aber nie
|
||||||
|
// bis auf nichts: „AG" allein ist ein Firmenname, kein Zusatz.
|
||||||
|
while (count($words) > 1 && in_array(end($words), self::LegalFormTokens, true)) {
|
||||||
|
array_pop($words);
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode(' ', $words);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zahl der wirklich verschiedenen Namen in einer Liste normalisierter
|
||||||
|
* Namen. Ähnliche werden gierig demselben Cluster zugeschlagen.
|
||||||
|
*
|
||||||
|
* @param list<string> $names
|
||||||
|
*/
|
||||||
|
private function countDistinct(array $names): int
|
||||||
|
{
|
||||||
|
$representatives = [];
|
||||||
|
|
||||||
|
foreach ($names as $name) {
|
||||||
|
foreach ($representatives as $representative) {
|
||||||
|
if ($this->similar($name, $representative)) {
|
||||||
|
continue 2;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $value;
|
$representatives[] = $name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return count($representatives);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function similar(string $a, string $b): bool
|
||||||
|
{
|
||||||
|
if ($a === $b) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$length = max(mb_strlen($a), mb_strlen($b));
|
||||||
|
$allowed = max(self::SimilarMaxDistance, (int) ceil($length * self::SimilarDistanceRatio));
|
||||||
|
|
||||||
|
// levenshtein() arbeitet auf Bytes und ist auf 255 begrenzt — die
|
||||||
|
// Namen sind zu diesem Zeitpunkt ASCII und kurz.
|
||||||
|
if (levenshtein(substr($a, 0, 255), substr($b, 0, 255)) <= $allowed) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$wordsA = explode(' ', $a);
|
||||||
|
$wordsB = explode(' ', $b);
|
||||||
|
|
||||||
|
return count($wordsA) >= self::SharedPrefixWords
|
||||||
|
&& count($wordsB) >= self::SharedPrefixWords
|
||||||
|
&& array_slice($wordsA, 0, self::SharedPrefixWords) === array_slice($wordsB, 0, self::SharedPrefixWords);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,51 @@ test('a differing legal form is the same company', function () {
|
||||||
expect(mergeClasses()['namensvariante']['faellt_weg'])->toBe(1);
|
expect(mergeClasses()['namensvariante']['faellt_weg'])->toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a legal form with ampersand is stripped like any other', function () {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
mergeCompany('info@welcon.de', 'Welcon Europe Ltd. & Co. KG');
|
||||||
|
mergeCompany('info@welcon.de', 'Welcon Europe GmbH & Co. KG');
|
||||||
|
|
||||||
|
// Im Live-Lauf vom 11.09. stand Welcon als „Agentur" — weil „& Co. KG"
|
||||||
|
// nach dem Entfernen der Satzzeichen auf keine Rechtsform mehr passte.
|
||||||
|
expect(mergeClasses()['namensvariante']['faellt_weg'])->toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('typos and small additions do not make a company an agency', function () {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
mergeCompany('info@js-research.de', 'JS Research');
|
||||||
|
mergeCompany('info@js-research.de', 'JS Research UG (haftungsbeschränkt)');
|
||||||
|
mergeCompany('info@js-research.de', 'S Research UG (haftungsbeschränkt)');
|
||||||
|
mergeCompany('info@js-research.de', 'JS Research');
|
||||||
|
|
||||||
|
$classes = mergeClasses(3);
|
||||||
|
|
||||||
|
// JS Research stand mit 204 Firmen an zweiter Stelle der Agenturliste —
|
||||||
|
// eine einzelne Firma, deren Name siebzehnmal leicht anders geschrieben
|
||||||
|
// wurde. Ein Tippfehler ist kein zweiter Kunde.
|
||||||
|
expect($classes['namensvariante']['adressen'])->toBe(1)
|
||||||
|
->and($classes['sammeladresse']['adressen'])->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a shared three-word prefix under one address is the same entity', function () {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
mergeCompany('praxis@prof-dhom.de', 'Praxis Prof. Dr. Dhom & Kollegen MVZ GmbH (Juli 2016)');
|
||||||
|
mergeCompany('praxis@prof-dhom.de', 'Praxis Prof. Dr. Dhom & Partner');
|
||||||
|
mergeCompany('praxis@prof-dhom.de', 'Praxis Prof. Dr. Dhom');
|
||||||
|
|
||||||
|
expect(mergeClasses(3)['namensvariante']['adressen'])->toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('genuinely different clients under one agency address stay distinct', function () {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
foreach (['Auto Hein', 'Ihr Personalberater', 'Designwerkstatt Coburg'] as $name) {
|
||||||
|
mergeCompany('presse@agentur-zb.de', $name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die Ähnlichkeitsregel darf echte Agenturen nicht wegnormalisieren.
|
||||||
|
expect(mergeClasses(3)['sammeladresse']['adressen'])->toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
test('two genuinely different names stay below the threshold as a proposal', function () {
|
test('two genuinely different names stay below the threshold as a proposal', function () {
|
||||||
/** @var TestCase $this */
|
/** @var TestCase $this */
|
||||||
mergeCompany('sandra@buschgroup.com', 'Busch Group');
|
mergeCompany('sandra@buschgroup.com', 'Busch Group');
|
||||||
|
|
@ -77,8 +122,8 @@ test('the threshold decides where a collection address begins', function () {
|
||||||
|
|
||||||
test('many names without a single publication are flagged, not merged', function () {
|
test('many names without a single publication are flagged, not merged', function () {
|
||||||
/** @var TestCase $this */
|
/** @var TestCase $this */
|
||||||
for ($i = 1; $i <= 30; $i++) {
|
foreach (range(1, 30) as $i) {
|
||||||
mergeCompany('yahoo@yahoo.com', "Firma {$i}");
|
mergeCompany('yahoo@yahoo.com', fake()->unique()->company().' '.fake()->unique()->word());
|
||||||
}
|
}
|
||||||
|
|
||||||
$classes = mergeClasses();
|
$classes = mergeClasses();
|
||||||
|
|
@ -93,8 +138,11 @@ test('many names without a single publication are flagged, not merged', function
|
||||||
|
|
||||||
test('a large but working agency is not mistaken for a placeholder', function () {
|
test('a large but working agency is not mistaken for a placeholder', function () {
|
||||||
/** @var TestCase $this */
|
/** @var TestCase $this */
|
||||||
for ($i = 1; $i <= 30; $i++) {
|
// Echte, verschiedene Kundennamen — „Kunde 1" bis „Kunde 30" lägen
|
||||||
mergeCompany('pr@agentur.ag', "Kunde {$i}", published: true);
|
// einander so nah, dass die Ähnlichkeitsregel sie zu Recht als eine
|
||||||
|
// Firma mit Filialnummern läse.
|
||||||
|
foreach (range(1, 30) as $i) {
|
||||||
|
mergeCompany('pr@agentur.ag', fake()->unique()->company().' '.fake()->unique()->word(), published: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Die Namensanzahl allein trennt nicht. Erst die Veröffentlichung tut es.
|
// Die Namensanzahl allein trennt nicht. Erst die Veröffentlichung tut es.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue