Google Maps APIを用いたジオコーディングで精度をチェックしよう

# はじめに
ジオコーディングをするときの変換した座標の精度を見るために、変換前の住所と変換後の座標についてくる住所データを比較します。

# 背景と目的
## 背景
当社ではガスや灯油の個人宅への配送を支援するサービスを提供しています。
そのサービスの中に配送順を算出する機能がありますが、とりわけ重要となるのが個人宅の座標取得（ジオコーディング）となります。
座標がずれると「おかしな配送順序になる」、「ナビアプリへ連携したときに変なところに案内される」などの問題が発生します。

### 基本的なジオコーディングの流れ
+ お客様から住所一覧のExcel/CSVファイルを受領
+ Google MapsのGeocoding APIを利用して座標を取得

### 課題
このとき、以下のような課題から座標がずれるケースが発生します。
+ 受領した住所の番地が何らかの理由で存在しない場合に、その地域の中心座標が返ってくる
+ 受領した住所の表記揺れ（漢字間違いや地元特有の記載）で、正確に変換できない

## 目的
前述の課題を軽減するためにジオコーディングする際に変換前後の住所を比較した精度チェックを実行し、ズレがあるものを検出していきます。

### 精度チェックでやること
+ 受領した住所の【若干の】表記ゆれの吸収
+ 変換後の座標の付帯情報に含まれる住所情報と受領した住所の突き合わせ

ここで新たな課題が発生します。
Googleさんがいい感じにある程度の表記の揺れを吸収してくれるので、受領した住所から変換した座標が正しいものながら、
座標の付帯情報の住所が受領した住所とずれるケースがあり、突き合わせに失敗するパターンがあります。
ここもできる限り対処していきます。

### やらないこと
+ 住所の正確性チェック： 基本的に受領した住所を正としているので、正しいかどうかのチェックはしません。お客様が持つデータとなるので、ここでは変更は加えません。
  + 例1： 市町村統合で住所自体が変わった
  + 例2： 住所の整理で住所表記や番地表記が変わった
  + 例3： 地元特有の表記
+ 座標ズレの補正： 座標がずれていても、正確なお客様住居がわからないため、そのままの座標とします
  + 例： 地域の中心座標

## 事前準備
### Google Maps Geocoding APIの挙動の確認と対策
付帯情報の住所は以下のサイトを参考にしました。
https://maps.multisoup.co.jp/blog/1952/

ここでポイントとなってくるのが、以下のデータとなります。
+ administrative_area_level_1： 都道府県情報。受領データでは省略されているケースが多い
+ administrative_area_level_2： 郡情報。くせ者。受領データでは記載有り記載なしが混在してるケースが多い
+ sublocality_level_3： 小字・丁目情報。受賞データでは「丁目」の表記がハイフンや長音など様々な表記が混在している
+ 加えて「大字」「字」「番地」「番」も表記があったりなかったり、ハイフンだったり長音だったりの混在している

まとめると都道府県と郡の有無、丁目等の表記ゆれが激しいとなります。
これの対策を考えると…
+ 都道府県有無の比較データ、郡データ有無の比較データの計4つの比較データの作成
+ 「丁目」に該当する表記をどこかで統一
+ 「大字」「字」は省略
+ 「番地」「番」は省略とハイフンパターンで比較

## 対処
以降はPythonでの初期を記載していきます。

### 受領データの標準化
前述の表記ゆれに加え、全角半角の数字やスペース除去も変換テーブルに入れていきます。
こう見るとハイフン長音ダッシュマイナス色々ありますね…。
```
trans_table = str.maketrans(
    {
        "0":"０",
        "1":"１",
        "2":"２",
        "3":"３",
        "4":"４",
        "5":"５",
        "6":"６",
        "7":"７",
        "8":"８",
        "9":"９",
        "-":"－",
        "ー":"－",
        "―":"－",
        "−":"－",
        "‐":"－",
        "号":"",
        "　":"",
        " ":""
        }
    )
```

この変換テーブルはtranslateで適用します。
番、番地は末尾につくケースがあるので、その場合には除去を、そうでない場合にはハイフンへ変換します。
```
if address.endswith('番地') or address.endswith('番'):
    normalized_address = address.replace('大字','').replace('字','').replace('番地','').replace('番','').translate(trans_table)
else:
    normalized_address = address.replace('大字','').replace('字','').replace('番地','－').replace('番','－').translate(trans_table)
```
### 比較用住所の作成
Google Maps Geocoding APIを利用すると以下のような結果が得られます。
座標とともにその座標の付帯情報として住所がついてきます。
この中で、address_componentsの各要素を使っていきます。

```
{
    'results': [
        {
            'address_components': [
                {'long_name': '３−５', 'short_name': '３−５', 'types': ['premise']}, 
                {'long_name': '二番町', 'short_name': '二番町', 'types': ['political', 'sublocality', 'sublocality_level_2']}, 
                {'long_name': '千代田区', 'short_name': '千代田区', 'types': ['locality', 'political']}, 
                {'long_name': '東京都', 'short_name': '東京都', 'types': ['administrative_area_level_1', 'political']}, 
                {'long_name': '日本', 'short_name': 'JP', 'types': ['country', 'political']}, 
                {'long_name': '102-0084', 'short_name': '102-0084', 'types': ['postal_code']}
            ], 
            'formatted_address': '日本、〒102-0084 東京都千代田区二番町３−５', 
            'geometry': {'location': {'lat': 35.685602, 'lng': 139.7373758}, 
            'location_type': 'ROOFTOP', 'viewport': {'northeast': {'lat': 35.68690138029149, 'lng': 139.7385493802915}, 
            'southwest': {'lat': 35.6842034197085, 'lng': 139.7358514197085}}}, 
            'place_id': 'ChIJT-T1gmSMGGARzZGPBKmiItk', 
            'plus_code': {'compound_code': 'MPPP+6X  日本、東京都千代田区', 'global_code': '8Q7XMPPP+6X'}, 
            'types': ['street_address']
        }
    ], 
    'status': 'OK'
}
```
これをみると、千代田区のため、郡データは存在しないのでadministrative_area_level_2のデータは含まれません。
また東京23区はsublocality_level_1がなく、localityで区を表したり、sublocality_level_3,4が無い等の動きになります。
これを元に、住所データの利用ルールをまとめます。

|type|項目|利用ルール|
|:---|:---|:---|
|administrative_area_level_1|都道府県|使うパターンと使わないパターンで分ける|
|administrative_area_level_2|郡|使うパターンと使わないパターンで分ける|
|locality|市町村と東京23区|使う|
|sublocality_level_1|政令指定都市の区|あれば使う|
|sublocality_level_2|大字・町|あれば使う|
|sublocality_level_3|小字・丁目|あれば使う|
|sublocality_level_4|街区|あれば使う|
|premise|地番・枝番|使う|

これをコードに落とすと、以下のとおりとなります。
少し前に書いたので、若干拙い感じがしますね…

```
# APIの実行結果から各要素を拾ってくる。各要素はBlankで初期化済み
for item in geocode_result['results'][0]['address_components']:
    if 'administrative_area_level_1' in item['types']:
        administrative_area1 = item['long_name']
    elif 'administrative_area_level_2' in item['types']:
        administrative_area2 = item['long_name']
    elif 'locality' in item['types']:
        locality = item['long_name']
    elif 'sublocality_level_1' in item['types']:
        sublocality1 = item['long_name']
    elif 'sublocality_level_2' in item['types']:
        sublocality2 = item['long_name']
    elif 'sublocality_level_3' in item['types']:
        sublocality3 = item['long_name']
    elif 'sublocality_level_4' in item['types']:
        sublocality4 = item['long_name']
    elif 'premise' in item['types']:
        premise = item['long_name'].translate(trans_table)

# まずは郡の有無でパターン分け        
result_address_include_admin2 = administrative_area2 + locality + sublocality1 + sublocality2 + sublocality3 + sublocality4
result_address_exclude_admin2 = locality + sublocality1 + sublocality2 + sublocality3 + sublocality4

#街区（番号）と地番・枝番はハイフンでつなぐ
if premise != '':
    if '丁目' in sublocality3 or (sublocality3 == '' and sublocality4 != ''):
        support_hyphen = '－'
        result_full_address_include_admin2 = result_address_include_admin2 + support_hyphen + premise
        result_full_address_exclude_admin2 = result_address_exclude_admin2 + support_hyphen + premise
```

### 住所の比較
標準化した住所とAPIで取得した住所を比較していきます。
住所比較の結果にはいくつかのレベルを設けています。
+ 一致（番地レベル）： APIの結果に地番・枝番が含まれており、受領した住所と一致
+ 一致（字丁目レベル・番地ズレ）： APIの結果に地番・枝番が含まれているが、受領した住所の地番・枝番と異なる
+ 一致（字丁目レベル・番地取得不可）： APIの結果に地番・枝番が含まれない
+ 不一致：　APIの結果と受領した住所が異なる

なお、受領した住所には建物名だったり、謎のメモが入るケースが多いので完全一致での比較は失敗の可能性が高いので、
前方一致を使って比較しています。
```
# APIの結果に地番・枝番がある場合、
# address_include_admin1とaddress_include_admin2がそれぞれの有無で4パターン x 丁目が含まれる場合の計8パターンの比較
if premise != '':
    if (normalized_address.startswith(result_full_address_include_admin2) 
    or normalized_address.startswith(result_full_address_exclude_admin2) 
    or normalized_address.startswith(administrative_area1 + result_full_address_include_admin2)
    or normalized_address.startswith(administrative_area1 + result_full_address_exclude_admin2)):
        result = '一致（番地レベル）'
    elif (normalized_address.startswith(result_full_address_include_admin2.replace('丁目','－'))
    or normalized_address.startswith(result_full_address_exclude_admin2.replace('丁目','－'))
    or normalized_address.startswith(administrative_area1 + result_full_address_include_admin2.replace('丁目','－'))
    or normalized_address.startswith(administrative_area1 + result_full_address_exclude_admin2.replace('丁目','－'))):
        result = '一致（番地レベル）'
   else:
       result = '不一致'
            
# APIの結果に地番・枝番が無い場合も8パターンで比較
else:
    if (normalized_address.startswith(result_address_include_admin2) 
    or normalized_address.startswith(result_address_exclude_admin2)
    or normalized_address.startswith(administrative_area1 + result_address_include_admin2)
    or normalized_address.startswith(administrative_area1 + result_address_exclude_admin2)):
        # 街区、地番・枝番が含まれている場合
        if re.search('[０１２３４５６７８９－]+?$', result_address_exclude_admin2):
            result = '一致（字丁目レベル・番地ズレ）'
        # 街区、地番・枝番が含まれていない場合
        else:
            result = '一致（字丁目レベル・番地取得不可）'
    elif (normalized_address.startswith(result_address_include_admin2.replace('丁目','－')) 
    or normalized_address.startswith(result_address_exclude_admin2.replace('丁目','－'))
    or normalized_address.startswith(administrative_area1 + result_address_include_admin2.replace('丁目','－'))
    or normalized_address.startswith(administrative_area1 + result_address_exclude_admin2.replace('丁目','－'))):
        if re.search('[０１２３４５６７８９－]+?$', result_address_exclude_admin2):
            result = '一致（字丁目レベル・番地ズレ）'
        else:
            result = '一致（字丁目レベル・番地取得不可）'
    else:
        result = '不一致'
```

## 今後の課題
都道府県と郡の扱いの処理の追加、番地の扱いを分割したことで大分変換の精度が向上しました。
一方で新たな課題も出てきたので、その一例と対策案を今後の課題として紹介します。

### 表記ゆれ
住所入力を人手で行っている限り常に発生する課題となります。
よくあるものだと「が」「ヶ」「ケ」がわかり易い例ですね。
ある程度はパターン登録でできるか？とか考えた時期もありましたが、茅ヶ崎市と横浜市都筑区茅ケ崎町のようなパターンが出てきて頭を抱えました。
これの対策には文字列の類似度を測る処理をいれ、大凡一致していたらOKとするような処理にしようかなと画策しています。
参考URL： http://pixelbeat.jp/text-matching-3-approach-with-python/

### Google Maps Geocoding APIの結果でsublocality_level_2、sublocality_level_3に街区データが混入する場合
まれに発生するやつです。sublocality_level_2やsublocality_level_3で地域名だけ出力される想定のため、
地域名の直後にハイフンは来ないものとして比較用住所を作っています。
そのため、ジオコーディングする際には千代田区二番町3-5と入れているのが、比較用の住所が千代田区二番町35となってしまいます。（例として使っていますが、弊社住所はちゃんと出ます。）
こちらは、返ってきた結果の末尾に数字が入っているかのチェックを入れて補助用のハイフンの有無を制御する処理にしようかなと画策しています。

# まとめ
ジオコーディング時の精度チェックのための住所比較を紹介してきました。
本手法を用いることで、精度が高く変換できたものと失敗したものの分類は手間をかけずに実行できるようになりました。
また、結果の住所も出てくるため、変換失敗したものが表記ゆれかそうでないかのチェックも即座に行え、お客様への相談もしやすくなりました。
前述の通り、まだ課題が残っているところもあるので、そちらも対応できたら（ついでにコードのリファインもできたら）あらため紹介したいなと考えています。