1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

地図に載せた数万件の店舗を「チェーン/ブランド別」に色分けする — 表記ゆれと戦った実装記録

1
Posted at

はじめに

個人でバイク情報ポータル「MotoHub」を開発しています。そのなかに、バイクショップ・駐車場・ガソリンスタンド・コンビニなどを一枚の地図にまとめた「ライダーズマップ」という機能があります。

このマップで、ただピンを並べるだけでなく、「どのチェーン店か」「どの石油ブランドか」をアイコンの色と形で見分けられるようにしたい、と考えました。ENEOSは赤、出光はオレンジ、セブン-イレブンは…というように、ブランドで色分けされていれば、地図の情報量が一気に上がります。

やってみると、これは「地図描画の話」ではなく 「バラバラの文字列から実体(ブランド)を特定するデータ分類の話」 でした。この記事では、その分類処理の設計と、途中でぶつかった表記ゆれ・誤判定との戦いを記録します。

技術スタックは Laravel 12 / PHP 8.3 / MySQL 8 です。ただ、考え方自体はフレームワークに依存しません。

何をやりたかったか

対象は大きく3系統ありました。

  1. バイクショップ … 店名からチェーン(レッドバロン、SOX など)を判定し、さらにメーカー正規ディーラーを別分類する
  2. 駐車場 … 運営会社名(management_company)から akippa などのブランドを判定する
  3. ガソリンスタンド・コンビニbrand フィールドから ENEOS / セブン-イレブンなどを判定する

共通する構造は「ある文字列フィールドを見て、それが既知のブランドのどれに該当するかを返す(該当しなければ null)」という一点です。まずはこの共通処理から設計しました。

素朴な実装とその限界

最初のチェーン判定は、こんな素朴なものでした。

public static function chainSlug(?string $name): ?string
{
    if ($name === null || $name === '') {
        return null;
    }
    foreach (config('bike.chains', []) as $slug => $chain) {
        if (str_contains($name, $chain['pattern'])) {
            return $slug;
        }
    }
    return null;
}

config にチェーンごとの pattern(例:'レッドバロン')を持たせ、店名に含まれていればそのチェーンと判定する。動くには動きます。

しかし現実のデータを入れると、たちまち破綻しました。以下のような店名が「同じチェーンなのに一致しない」のです。

  • REVERSE AUTOReverse Autoリバースオート
  • SBS(全角)と SBS(半角)
  • カワサキ プラザ横浜(半角スペースあり)と カワサキプラザ横浜

単純な str_contains は、全角・半角、大文字・小文字、空白の有無、カタカナ・英字の違いを一切吸収しません。表記ゆれの前では無力でした。

正規化してから照合する

そこで、照合の前に文字列を正規化するようにしました。PHPには mb_convert_kana という便利な関数があります。

private static function normalizeForMatch(string $s): string
{
    // 全角英数→半角、半角カナ→全角カナ、小文字化、空白除去
    return mb_strtolower(
        str_replace([' ', ' '], '', mb_convert_kana($s, 'aKVs'))
    );
}

mb_convert_kana のフラグは覚えにくいですが、今回使ったのは以下です。

  • a … 全角英数字・記号 → 半角
  • K … 半角カタカナ → 全角カタカナ
  • V … 濁点付き半角カナを1文字に結合(K と併用)
  • s … 全角スペース → 半角スペース

これに mb_strtolower(小文字化)と空白除去を組み合わせます。照合する両辺(データ側と config のパターン側)を必ず同じ関数に通すのがポイントです。片側だけ正規化しても一致しません。

\s を使った空白除去は要注意です。PCRE の \s/u を付けても全角スペース U+3000 を拾いません。全角スペースを消したいなら str_replace(' ', '', $s) のように明示するか、[\s\x{3000}] と書く必要があります。

これで SBSカワサキ プラザ横浜 も吸収できるようになりました。

正規化だけでは越えられない壁:カナ⇔英字

ところが、正規化してもどうしても一致しないケースが残りました。

  • REVERSE AUTO → 正規化すると reverseauto
  • リバースオート → 正規化しても リバースオート(カタカナのまま)

mb_convert_kanaカタカナと英字(ローマ字)を相互変換しません。「リバースオート」と「reverse auto」は、人間には同じでも、機械にとっては完全に別の文字列です。

これは正規化では原理的に解決できないので、パターン側を「別名の配列」にして、両方の表記を登録する方針にしました。config のスキーマを pattern(単数)から patterns(配列)へ拡張します。

'chains' => [
    // 単数patternと配列patternsの両対応(既存を壊さない)
    'red-baron'    => ['name' => 'レッドバロン', 'patterns' => ['レッドバロン', 'red baron']],
    'bikeo'        => ['name' => 'バイク王',     'pattern'  => 'バイク王'],
    'reverse-auto' => ['name' => 'リバースオート', 'patterns' => ['リバースオート', 'reverse auto']],
],

照合側は、patterns があればそれを、なければ従来の pattern を単一要素配列として扱い、いずれかにマッチすれば採用、とします。カナ形とローマ字形を両方登録することで、mb_convert_kana では越えられなかった壁をデータ側で吸収しました。

教訓:スキーマ変更は「読み手」を全部探す

この patternpatterns の変更で、いきなり本番想定のテストが軒並み落ちました。原因は単純で、configchains を読んでいたのは判定メソッドだけではなかったのです。別のコントローラが、まだ古い $chain['pattern'] を直接読んでいました。

Undefined array key "pattern"

patterns しか持たないチェーンで、このコントローラが500エラーを吐いていました。判定ロジックは新スキーマに対応済みでも、同じ設定を読んでいる別の場所が取り残されていたわけです。

対策として、全消費箇所を洗い出しました。

grep -rn "\['pattern'\]" app resources

そして、コントローラ側も独自の str_contains ループをやめ、正規化対応済みの判定メソッド(chainSlug())に寄せました。同じ判定は一箇所に集約する。当たり前ですが、スキーマを変えるときは「そのデータを読んでいる全員」を探すのが鉄則だと再認識しました。

このバグはテストがなければ本番で全チェーン横断ページが500になっていました。マージ前にフルスイートを回していて救われました。

「確実なものだけ分類する」というポリシー

次に駐車場です。駐車場は運営会社名(management_company)で分類しますが、ここで方針が問われました。

データを実際に集計すると、management_company はこうなっていました(上位・抜粋)。

27177  akippa株式会社
10667  株式会社アース・カー
  339  公益財団法人自転車駐車場整備センター
  251  札幌市建設局総務部自転車対策担当課
  237  Ecostation
  ...
   18  仙台市
   16  所沢市

akippa のような明快なブランドもあれば、「仙台市」「所沢市」のような自治体名、「◯◯市建設局△△課」のような部署名まで、粒度がバラバラです。

「全部を細かく色分けしたい」という気持ちもありましたが、色は人間が見分けられる数に限界があります(せいぜい8色程度)。数百種類を別々の色にしても、似た色だらけで判別不能になり、地図はむしろ見づらくなります。

そこで「確実に判別できるブランドだけに固有色を与え、それ以外は控えめな共通表示にする」というポリシーにしました。分類できないものを無理に分類しない。これは以降のすべての分類処理で一貫させた方針です。

「市名で終わるか」で機械的に切る

このとき、「地図で運営が分かるバッジを出すもの」と「ただの自治体だから出さないもの」を、どう機械的に切り分けるかが問題になりました。

最初は「これは会社っぽい/これは役所っぽい」という人間の感覚で線を引こうとして、うまくいきませんでした。その基準はコードに落とせないからです。

代わりに見つけたルールが「正規化後の末尾が市区町村名で終わるか」でした。

  • 仙台市 所沢市 … 末尾が「市」で終わる → 自治体そのもの → バッジなし
  • 岡山市北区役所維持管理課 自転車・駐車場係 … 「市」の後に組織名が続く → バッジあり

つまり /[市区町村]$/u にマッチすれば自治体扱い、それ以外は具体的な運営組織あり、と切れます。あいまいな感覚を、末尾文字という明快なルールに落とし込めました。

実装前に、このルールが実データで本当に想定通り切れるか、全件を集計して検証しました。

$named = BikeParking::pluck('management_company')
    ->reject(fn ($v) => $v === null || trim((string) $v) === '')
    ->map($clean);

$A = $named->filter(fn ($v) => preg_match('/[市区町村]$/u', $v)); // 自治体
$B = $named->reject(fn ($v) => preg_match('/[市区町村]$/u', $v)); // 組織あり

結果、想定どおり「仙台市 → 自治体」「◯◯市△△課 → 組織あり」に分かれることを確認してから実装に入りました。ルールを思いついたら、コードを書く前に実データで検算する。これも今回徹底した点です。

統合履歴という沼:ガソリンスタンド

最後に、いちばん手強かったガソリンスタンドです。データは OpenStreetMap 由来で、brand フィールドを持っていますが、中身の品質は駐車場とは比べものになりませんでした。

brand を集計すると、同じブランドが表記ゆれで散らばっているうえ、業界の統合履歴まで絡んでいました

3967  ENEOS
1292  エネオス
 167  Esso     … 旧エッソ(現ENEOS)
  88  Mobil    … 旧モービル(現ENEOS)
  57  ゼネラル  … 旧ゼネラル(現ENEOS)
  23  JOMO     … 旧ジャパンエナジー(現ENEOS)
 712  apollostation … 出光のSSブランド
 268  昭和シェル      … 出光と統合
 ...

エッソ・モービル・ゼネラル・JOMO は、いずれも合併・統合で現在はENEOSです。apollostation・昭和シェル・Shell は出光に統合されています。単なる表記ゆれではなく、「どの旧ブランドがどこに吸収されたか」という業界知識がなければ正しく束ねられません。

これらを patterns で一つのブランドに束ねます。

'eneos' => [
    'name' => 'ENEOS',
    'patterns' => [
        'eneos', 'エネオス', 'enejet',
        'esso', 'エッソ', 'mobil', 'モービル',
        'ゼネラル', 'general', 'jomo', '日石',
        // …実データに存在した表記のみ
    ],
],

束ね表を作るときの鉄則は「実データに存在する表記だけを登録する」です。統合履歴の知識から「JXTGもあるはず」と憶測で綴りを足しても、データになければ無駄ですし、逆にデータにある綴りを見落とせば取りこぼします。必ず全件を集計し、そこに現れた文字列だけを対象にしました。

部分一致の誤爆を防ぐ

ブランド判定は結局のところ部分一致ですが、短いキーワードは危険です。実際に警戒したケース。

  • JA … 農協系SS(JA-SS)を拾いたいが、JAFJAL、ローマ字の "ja" を含む語まで巻き込む。→ bare な ja は禁止し、ja-ss jass 全農 などの実トークンで拾う
  • 三菱三菱石油 は歴史的にENEOS系だが、三菱商事エネルギー は別物(多ブランド販売店)。→ 三菱石油 は完全一致で ENEOS、三菱商事〜 は「その他」へ
  • cosmos … コスモ石油の綴りゆれか、コスモス薬品(無関係)か判別不能。→ 安全側で「その他」に落とす
  • 韓国語ブランドGS칼텍스 など)や、明らかな非GS(LAWSONIsuzuコストコ、水素・都市ガス系)… → 除外リストで先に弾く

判定順序を「正規化 → 除外リスト → 固有ブランド照合 → その他」とし、誤爆しやすいものを先に殺す構成にしました。

できあがった分類パイプライン

3系統に共通する処理の形はこうです。

  1. 対象の文字列フィールドを取得(店名 / management_company / brand
  2. mb_convert_kana ベースで正規化
  3. 明らかなノイズを除外リストで弾く
  4. config のブランド定義(patterns 別名配列)と照合
  5. 該当すればブランドキー、しなければ null または「その他」を返す

config 駆動にしておくと、あとから「このブランドも色分けしたい」となったときに 1行足すだけ で済みます。実装コードは触りません。この拡張性が、config にブランド定義を寄せた最大の狙いでした。

地図側(フロント)では、返ってきたブランドキーごとに 色と形を割り当てます。今回は系統ごとに形を分けました。

  • バイクショップ … 丸
  • 駐車場 … タグ形
  • ガソリンスタンド … ひし形
  • コンビニ … 六角形

色だけだと系統をまたいで似た色が並んだとき紛らわしいですが、形で系統を、色でブランドを表すことで、全レイヤーを同時に表示しても見分けられるようにしました。

分類のたびに徹底したこと

3系統すべてで、実装前に必ず同じ手順を踏みました。

  1. 全件を集計して分布を見るGROUP BY して件数順に眺める)
  2. その分布を見て、固有色を与える対象と、除外/その他の線引きを決める
  3. spot-check:代表的な入力(統合前ブランド、誤爆しそうな短語、ノイズ)が期待どおり分類されるか、実装前に検算する
  4. 回帰テストを書いてから実装

とくに効いたのが 1 と 3 です。「1件のサンプルだけ見て設計する」と、必ずどこかで想定外の表記に殴られます。全体の分布を見てから設計し、書く前に検算する。地味ですが、これがいちばんの近道でした。

まとめ

「地図をブランドで色分けする」という見た目の機能は、実装してみると データクレンジングと分類設計の塊 でした。得られた教訓を並べておきます。

  • 表記ゆれは mb_convert_kana による正規化で大半を吸収できる(両辺を同じ関数に通す)
  • ただしカナ⇔英字は正規化では越えられない。別名配列で持つ
  • config にスキーマを変えるときは、その設定を読む全箇所grep で洗う
  • 分類できないものを無理に分類しない。確実なものだけに固有色を与える
  • あいまいな線引きは、末尾文字などの機械的ルールに落とせないか探す
  • ブランド束ねは業界の統合履歴が絡む。ただし 実データに存在する表記だけ を登録する
  • 部分一致は誤爆する。除外を先に、短い曖昧キーワードは避ける
  • 設計の前に全件集計、実装の前にspot-checkで検算

個人開発でここまでのデータ量(駐車場だけで約4.4万件)を扱うと、こうした地道なクレンジングの比重が大きくなります。派手さはありませんが、地図の見やすさに直結する部分でした。同じように「雑多な文字列を分類したい」場面の参考になれば幸いです。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?