はじめに
個人でバイク情報ポータル「MotoHub」を開発しています。そのなかに、バイクショップ・駐車場・ガソリンスタンド・コンビニなどを一枚の地図にまとめた「ライダーズマップ」という機能があります。
このマップで、ただピンを並べるだけでなく、「どのチェーン店か」「どの石油ブランドか」をアイコンの色と形で見分けられるようにしたい、と考えました。ENEOSは赤、出光はオレンジ、セブン-イレブンは…というように、ブランドで色分けされていれば、地図の情報量が一気に上がります。
やってみると、これは「地図描画の話」ではなく 「バラバラの文字列から実体(ブランド)を特定するデータ分類の話」 でした。この記事では、その分類処理の設計と、途中でぶつかった表記ゆれ・誤判定との戦いを記録します。
技術スタックは Laravel 12 / PHP 8.3 / MySQL 8 です。ただ、考え方自体はフレームワークに依存しません。
何をやりたかったか
対象は大きく3系統ありました。
- バイクショップ … 店名からチェーン(レッドバロン、SOX など)を判定し、さらにメーカー正規ディーラーを別分類する
-
駐車場 … 運営会社名(
management_company)から akippa などのブランドを判定する -
ガソリンスタンド・コンビニ …
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 AUTOとReverse 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 では越えられなかった壁をデータ側で吸収しました。
教訓:スキーマ変更は「読み手」を全部探す
この pattern → patterns の変更で、いきなり本番想定のテストが軒並み落ちました。原因は単純で、config の chains を読んでいたのは判定メソッドだけではなかったのです。別のコントローラが、まだ古い $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)を拾いたいが、JAFやJAL、ローマ字の "ja" を含む語まで巻き込む。→ bare なjaは禁止し、ja-ssjass全農などの実トークンで拾う -
三菱…三菱石油は歴史的にENEOS系だが、三菱商事エネルギーは別物(多ブランド販売店)。→三菱石油は完全一致で ENEOS、三菱商事〜は「その他」へ -
cosmos… コスモ石油の綴りゆれか、コスモス薬品(無関係)か判別不能。→ 安全側で「その他」に落とす -
韓国語ブランド(
GS칼텍스など)や、明らかな非GS(LAWSON、Isuzu、コストコ、水素・都市ガス系)… → 除外リストで先に弾く
判定順序を「正規化 → 除外リスト → 固有ブランド照合 → その他」とし、誤爆しやすいものを先に殺す構成にしました。
できあがった分類パイプライン
3系統に共通する処理の形はこうです。
- 対象の文字列フィールドを取得(店名 /
management_company/brand) -
mb_convert_kanaベースで正規化 - 明らかなノイズを除外リストで弾く
-
configのブランド定義(patterns別名配列)と照合 - 該当すればブランドキー、しなければ
nullまたは「その他」を返す
config 駆動にしておくと、あとから「このブランドも色分けしたい」となったときに 1行足すだけ で済みます。実装コードは触りません。この拡張性が、config にブランド定義を寄せた最大の狙いでした。
地図側(フロント)では、返ってきたブランドキーごとに 色と形を割り当てます。今回は系統ごとに形を分けました。
- バイクショップ … 丸
- 駐車場 … タグ形
- ガソリンスタンド … ひし形
- コンビニ … 六角形
色だけだと系統をまたいで似た色が並んだとき紛らわしいですが、形で系統を、色でブランドを表すことで、全レイヤーを同時に表示しても見分けられるようにしました。
分類のたびに徹底したこと
3系統すべてで、実装前に必ず同じ手順を踏みました。
-
全件を集計して分布を見る(
GROUP BYして件数順に眺める) - その分布を見て、固有色を与える対象と、除外/その他の線引きを決める
- spot-check:代表的な入力(統合前ブランド、誤爆しそうな短語、ノイズ)が期待どおり分類されるか、実装前に検算する
- 回帰テストを書いてから実装
とくに効いたのが 1 と 3 です。「1件のサンプルだけ見て設計する」と、必ずどこかで想定外の表記に殴られます。全体の分布を見てから設計し、書く前に検算する。地味ですが、これがいちばんの近道でした。
まとめ
「地図をブランドで色分けする」という見た目の機能は、実装してみると データクレンジングと分類設計の塊 でした。得られた教訓を並べておきます。
- 表記ゆれは
mb_convert_kanaによる正規化で大半を吸収できる(両辺を同じ関数に通す) - ただしカナ⇔英字は正規化では越えられない。別名配列で持つ
-
configにスキーマを変えるときは、その設定を読む全箇所をgrepで洗う - 分類できないものを無理に分類しない。確実なものだけに固有色を与える
- あいまいな線引きは、末尾文字などの機械的ルールに落とせないか探す
- ブランド束ねは業界の統合履歴が絡む。ただし 実データに存在する表記だけ を登録する
- 部分一致は誤爆する。除外を先に、短い曖昧キーワードは避ける
- 設計の前に全件集計、実装の前にspot-checkで検算
個人開発でここまでのデータ量(駐車場だけで約4.4万件)を扱うと、こうした地道なクレンジングの比重が大きくなります。派手さはありませんが、地図の見やすさに直結する部分でした。同じように「雑多な文字列を分類したい」場面の参考になれば幸いです。