郵便番号データをtidyにする挑戦
日本人が頻繁に遭遇するデータ操作を効率的に行うための{zipangu}
パッケージ、想定よりも多くの人が喜んでくれたようで、私としても嬉しく思っています。
はてなブログに投稿しました #はてなブログ
— Uryu Shinya (@u_ribo) 2019年12月2日
住所や年号、漢数字のデータ操作を楽にするRパッケージをCRANに登録しました - cucumber fleshhttps://t.co/5I3rntfrio
記事の最後にプロジェクトの協力者を募集したら数名からの反応があり、また新機能の要望も挙げられました。 ありがとうございます。
さて、次のリリースでは郵便番号の処理を効率的に行う機能を実装する計画でいます。 具体的には日本郵便が提供する郵便番号csvファイル(以下、郵便番号データ)をRで読み込む関数と、郵便番号の検索および住所情報を返却する機能です。
Add japan zip-code utility functions by uribo · Pull Request #6 · uribo/zipangu · GitHub
ファイルの読み込みに関してはすでにmasterブランチへマージされています。 そして郵便番号検索の方もここで読み込んだファイルを利用すれば良かろうと思っていたのですが、こんなご意見をいただきました。
まじか、Pythonに続いてRもken_ALL.csvの闇に突貫するのか… https://t.co/MKV7wjdJpa
— Aki Ariga (@chezou) 2019年12月10日
どうもこの郵便番号データには問題があるそうです。探ってみましょう。
install.packages("remotes") remotes::install_github("uribo/zipan")
library(dplyr) library(zipangu) library(stringr)
# read_zipcode() が郵便番号データを読み込むための関数です。 # 提供されている3種類(読み仮名データの促音・拗音を小書きで表記しないもの、読み仮名データの促音・拗音を小書きで表記するもの、ローマ字)の住所の表記形式、事業所のcsvファイルに対応します # path引数にzipファイルが置かれるURLまたはコンピュータ上のファイルパスを指定します df <- read_zipcode(path = "https://www.post.japanpost.jp/zipcode/dl/oogaki/zip/ken_all.zip", type = "kogaki") %>% # 市区町村コード、郵便番号、住所に関する列、重複の判定のために「丁目を有する町域の場合の表示」の列を選んでおきます select(jis_code, zip_code, prefecture, city, street, is_cyoumoku)
例えば郵便番号066-0005
のレコードを検索すると次のように3件のデータが返却されます。同じ郵便番号、市町村なのになぜ?となりますが、street列で表示している町域名がおかしいことに気がつきます。
df %>% filter(zip_code == "0660005")
jis_code | zip_code | prefecture | city | street | is_cyoumoku |
---|---|---|---|---|---|
01224 | 0660005 | 北海道 | 千歳市 | 協和(88−2、271−10、343−2、404−1、427− | 0 |
01224 | 0660005 | 北海道 | 千歳市 | 3、431−12、443−6、608−2、641−8、814、842− | 0 |
01224 | 0660005 | 北海道 | 千歳市 | 5、1137−3、1392、1657、1752番地) | 0 |
なんと、これは郵便番号データ番号ファイルの仕様です。データの説明書きに次の記述があります。
全角となっている町域部分の文字数が38文字を越える場合、また半角となっているフリガナ部分の文字数が76文字を越える場合は、複数レコードに分割しています
このままでは検索用の関数を用意する際に問題になります。
また、これ以外にも数々の問題点があり、これまでに多くの方が記事にまとめてくださっています。 この行の分割をはじめとしていくつかの問題への対策方法が書かれている記事も見受けられました。
一方で、次の2点に関する具体的な処理方法については見つけることができませんでした。
藤野(400、400−2番地)
など「、」で複数の住所がある –>藤野400
、藤野400-2番地
の行を分ける大通西(1〜19丁目)
のように住所が省略される –>大通西1丁目
、大通西2丁目
、大通西3丁目
、…大通西19丁目
を独立させる
以下に示すように、元のデータはtidy1ではありません。 扱うデータがtidyであることを心がける身としては放っては置けない問題です。
df %>% filter(zip_code %in% c("0050840", "0600042"))
jis_code | zip_code | prefecture | city | street | is_cyoumoku |
---|---|---|---|---|---|
01101 | 0600042 | 北海道 | 札幌市中央区 | 大通西(1〜19丁目) | 1 |
01106 | 0050840 | 北海道 | 札幌市南区 | 藤野(400、400−2番地) | 0 |
このデータをtidyにするならこうかなと思います (大通西の住所は一部省略)
jis_code | zip_code | prefecture | city | street |
---|---|---|---|---|
01106 | 0050840 | 北海道 | 札幌市南区 | 藤野400 |
01106 | 0050840 | 北海道 | 札幌市南区 | 藤野400-2番地 |
01101 | 0600042 | 北海道 | 札幌市中央区 | 大通西1丁目 |
01101 | 0600042 | 北海道 | 札幌市中央区 | 大通西2丁目 |
そんなわけで前置きが長くなりましたが、こうした問題の解決に取り組んでいます。 いくつかの課題に関しては解決できそうと目処が立つ、一方で完璧には程遠いことを感じてきたので一旦整理しておきます。
目次
住所の重複と複数行に分割される問題への対処
住所の重複と複数行に分割される問題には、これまでに書かれている記事を参考に次の関数を用意することで対処しました。関数のコードはGitHubに置いてますので興味のある人は見てください。読み込んだ郵便番号データに次の関数を適用することで同一住所の行に正しく住所が格納されるようになります(複数行の先頭と終了の行番号を特定し、その間の町域名を結合するだけ)。
df_tidying <- df %>% zip_tidy_prep()
まずは住所の重複の確認から。重複のあるデータは is_cyoumoku
が2つの値を持っているのですが、単純に郵便番号と住所を紐付ける目的であればユニークに扱ってしまうのが良いです。
df %>% distinct(jis_code, street, is_cyoumoku, .keep_all = TRUE) %>% count(jis_code, zip_code, street, sort = TRUE) %>% filter(n > 1)
## # A tibble: 2 x 4
## jis_code zip_code street n
## <chr> <chr> <chr> <int>
## 1 27212 5810027 八尾木 2
## 2 28203 6730012 和坂 2
df_tidying %>% distinct(jis_code, street, is_cyoumoku, .keep_all = TRUE) %>% count(jis_code, zip_code, street, sort = TRUE) %>% filter(n > 1)
## # A tibble: 0 x 4
## # … with 4 variables: jis_code <chr>, zip_code <chr>, street <chr>, n <int>
df_tidying %>% select(-is_cyoumoku) %>% filter(zip_code %in% c("5810027", "6730012"))
## # A tibble: 2 x 6
## rowid jis_code zip_code prefecture city street
## <int> <chr> <chr> <chr> <chr> <chr>
## 1 57540 27212 5810027 大阪府 八尾市 八尾木
## 2 72807 28203 6730012 兵庫県 明石市 和坂
一行ずつのデータになっていますね。
では続いて、複数行にまたがって記録される町域名です。
# streetの値だけを取り出します addr <- df_tidying %>% filter(zip_code == "0660005") %>% pull(street) addr
## [1] "協和(88−2、271−10、343−2、404−1、427−5、1137−3、1392、1657、1752番地)"
ここまでは難なくでした。言わば通常のデュエル。ここからが闇のゲームの始まりです。
「、」で区切られた住所を分割する
冒頭にあげたように、tidyデータの理念に基づくと町域名の「、」ごとに行を分けるのが妥当な処理です。これをやってみましょう。まずは愚直に
str_split()
で「、」の位置で要素を分解します。
addr %>% str_split("、", simplify = TRUE)
## [,1] [,2] [,3] [,4] [,5] [,6] [,7] [,8]
## [1,] "協和(88−2" "271−10" "343−2" "404−1" "427−5" "1137−3" "1392" "1657"
## [,9]
## [1,] "1752番地)"
要素に分解できたものの、次は共通の住所文字列をつけたり(ここでは先頭および末尾に「協和」と「番地」を与えることになります)、余分な括弧を取り除く作業が残ります。それを行う関数も書きました。
addr %>% split_inside_address()
## [1] "協和88−2番地" "協和271−10番地" "協和343−2番地" "協和404−1番地"
## [5] "協和427−5番地" "協和1137−3番地" "協和1392番地" "協和1657番地"
## [9] "協和1752番地"
他のデータでも見てみましょう。今度は末尾に「番地」がつかない例です。
split_inside_address(str = "天王(追分、追分西、上北野、長沼)")
## [1] "天王追分" "天王追分西" "天王上北野" "天王長沼"
これをデータフレームに適用します。
# 元の町域名を上書きします df_tidying %>% filter(zip_code %in% c("0660005")) %>% mutate(split_street = purrr::pmap(., ~ split_inside_address(..6))) %>% tidyr::unnest(cols = split_street) %>% select(-street) %>% rename(street = split_street) %>% select(names(df_tidying))
rowid | jis_code | zip_code | prefecture | city | street | is_cyoumoku |
---|---|---|---|---|---|---|
9558 | 01224 | 0660005 | 北海道 | 千歳市 | 協和88−2番地 | 0 |
9558 | 01224 | 0660005 | 北海道 | 千歳市 | 協和271−10番地 | 0 |
9558 | 01224 | 0660005 | 北海道 | 千歳市 | 協和343−2番地 | 0 |
9558 | 01224 | 0660005 | 北海道 | 千歳市 | 協和404−1番地 | 0 |
9558 | 01224 | 0660005 | 北海道 | 千歳市 | 協和427−5番地 | 0 |
9558 | 01224 | 0660005 | 北海道 | 千歳市 | 協和1137−3番地 | 0 |
9558 | 01224 | 0660005 | 北海道 | 千歳市 | 協和1392番地 | 0 |
9558 | 01224 | 0660005 | 北海道 | 千歳市 | 協和1657番地 | 0 |
9558 | 01224 | 0660005 | 北海道 | 千歳市 | 協和1752番地 | 0 |
よしよし、と思いきや、我々(私)は次の問題に直面するのです!
京都市内の通り名
京都市内で伝統的に使われる住所の表記形式として「通り名」があります。「上る」や「東入」などがつくやつ。
この表記は当然郵便番号データにも記録されています。
addr_historical <- df_tidying %>% filter(str_detect(street, "上る"), zip_code == "6048042") %>% pull(street) addr_historical
## [1] "中之町(寺町通錦小路下る、寺町通四条上る、新京極通錦小路下る、新京極通四条上る、錦小路通寺町東入、裏寺町通蛸薬師下る、裏寺町通四条上る)"
ここでは表記上では「中之町」が末尾に来るようにしなくてはいけません。マピオンのウェブページではキチンんと処理されています。そのため、先ほどの処理を適用すると次のようになるのでダメです。
#> [1] "中之町寺町通錦小路下る" "中之町寺町通四条上る" "中之町新京極通錦小路下る" "中之町新京極通四条上る" "中之町錦小路通寺町東入"
#> [6] "中之町裏寺町通蛸薬師下る" "中之町裏寺町通四条上る"
そこで京都市内で使われる通り名かを判定する関数を用意し、通り名の住所であれば「中之町」のような街名を末尾に移動させるようにしました。
is_jhistorical_street(addr_historical)
## [1] TRUE
is_jhistorical_street(addr)
## [1] FALSE
split_inside_address(addr_historical)
## [1] "寺町通錦小路下る中之町" "寺町通四条上る中之町"
## [3] "新京極通錦小路下る中之町" "新京極通四条上る中之町"
## [5] "錦小路通寺町東入中之町" "裏寺町通蛸薬師下る中之町"
## [7] "裏寺町通四条上る中之町"
これで単純なパターンであれば対応可能となりました。次に待ち構えるのは中ボスです。
複雑な町域名
実は先ほどのsplit_inside_address()
が対応できる括弧内の文字列の処理は、括弧が一つの場合のみです。次のように丸括弧と鉤括弧が使われていると正しく処理できません。
df_nest_bracket <- df_tidying %>% filter(str_detect(street, "「.+、.+」|「.+」、")) df_nest_bracket %>% pull(street)
## [1] "葛巻(第40地割「57番地125、176を除く」〜第45地割)"
## [2] "犬落瀬(内金矢、内山、岡沼、金沢、金矢、上淋代、木越、権現沢、四木、七百、下久保「174を除く」、下淋代、高森、通目木、坪毛沢「2沢、南平、柳沢、大曲)"
## [3] "折茂(今熊「213〜234、240、247、262、266、27大原、沖山、上折茂「1−13、71−192を除く」)"
## [4] "大江(1丁目、2丁目「651、662、668番地」以外、3丁目5、13−4、20、678、687番地)"
## [5] "泉沢(烏帽子「榛名湖畔」、烏帽子国有林77林班)"
## [6] "茂田井(1〜500「211番地を除く」「古町」、2527〜2529「土遠」)"
## [7] "牧之原(250〜343番地「255、256、258、259、262、276、294〜300、302〜304番地を除く」)"
## [8] "山田町下谷上(大上谷、修法ケ原、中一里山「9番地の4、12番地を除く」、長尾山、再度公園)"
## [9] "山田町下谷上(菊水山、高座川向、中一里山「9番地の4、12番地」、念仏堂、ひよどり越)"
# 鉤括弧の「、」で分割してしまうので良くない split_inside_address(df_nest_bracket$street[1])
## [1] "葛巻第40地割「57番地125" "葛巻176を除く」〜第45地割"
これについては「鉤括弧内の『、』については区切り文字のルールから除外する」正規表現を書けば良いのですが、私の技術力不足でできていません。幸い、こうしたデータの件数は9つと少ないので、今の所は該当データを発見したら鉤括弧以外の「、」を「_」に変更し、共通名称をつけた名前に変換する処理を取っています。
street_fix_keys <- c(`葛巻(第40地割「57番地125、176を除く」〜第45地割)` = paste0("葛巻", c("第40地割「57番地125、176を除く」", "第41地割", "第42地割", "第43地割", "第44地割", "第45地割"), collapse = "_"))
recode(df_nest_bracket$street[1], !!!street_fix_keys)
## [1] "葛巻第40地割「57番地125、176を除く」_葛巻第41地割_葛巻第42地割_葛巻第43地割_葛巻第44地割_葛巻第45地割"
これによりtidyr::separate_rows()
の行方向への分割が適用可能となります。
df_nest_bracket %>% mutate(street = street %>% recode(!!!street_fix_keys)) %>% tidyr::separate_rows(street, sep = "_")
## # A tibble: 51 x 7
## rowid jis_code zip_code prefecture city street is_cyoumoku
## <int> <chr> <chr> <chr> <chr> <chr> <dbl>
## 1 3973 03302 0285102 岩手県 岩手郡葛巻町… 葛巻第40地割「57番地125、176… 0
## 2 3973 03302 0285102 岩手県 岩手郡葛巻町… 葛巻第41地割 0
## 3 3973 03302 0285102 岩手県 岩手郡葛巻町… 葛巻第42地割 0
## 4 3973 03302 0285102 岩手県 岩手郡葛巻町… 葛巻第43地割 0
## 5 3973 03302 0285102 岩手県 岩手郡葛巻町… 葛巻第44地割 0
## 6 3973 03302 0285102 岩手県 岩手郡葛巻町… 葛巻第45地割 0
## 7 5035 02405 0330071 青森県 上北郡六戸町… 犬落瀬内金矢 0
## 8 5035 02405 0330071 青森県 上北郡六戸町… 犬落瀬内山 0
## 9 5035 02405 0330071 青森県 上北郡六戸町… 犬落瀬岡沼 0
## 10 5035 02405 0330071 青森県 上北郡六戸町… 犬落瀬金沢 0
## # … with 41 more rows
「〜」によって省略される住所を復元する
郵便番号データをtidyにするための2つ目の条件です。もう一度問題を確認しておきましょう。
addr <- df_tidying %>% filter(zip_code == "0600042") %>% pull(street) addr
## [1] "大通西(1〜19丁目)"
ここでの「大通西(1〜19丁目)」は「〜」によって省略されています。我々にはこの中に「大通西2丁目」や「大通西18丁目」が含まれていることが推測できますがコンピュータに識別させるのは簡単ではありません。「大通西2丁目」で検索した時にコンピュータこの住所を見つけてもらうための簡単な方法は省略された数値を用意してあげることです。そこで、「〜」によって省略される数値の最小値と最大値の範囲に含まれる数値を住所文字列と組み合わせて復元する以下の関数を用意しました。
split_seq_address(str = addr, split_chr = "〜", prefix = "大通西", suffix = "丁目", seq = TRUE)
## [1] "大通西1丁目" "大通西2丁目" "大通西3丁目" "大通西4丁目" "大通西5丁目"
## [6] "大通西6丁目" "大通西7丁目" "大通西8丁目" "大通西9丁目" "大通西10丁目"
## [11] "大通西11丁目" "大通西12丁目" "大通西13丁目" "大通西14丁目" "大通西15丁目"
## [16] "大通西16丁目" "大通西17丁目" "大通西18丁目" "大通西19丁目"
区切りが2つだけ、つまり「〜」ではなく「、」で分割される時はこうします。prefix
とsuffix
引数を用意することで柔軟な住所文字列を作ることが可能となりました。
split_seq_address(str = "吾妻1、2丁目", split_chr = "、", prefix = "吾妻", suffix = "丁目", seq = FALSE)
## [1] "吾妻1丁目" "吾妻2丁目"
こうしたデータは多数あり、「丁目」を含むものの他「番地」「線」が使われるものなどバリエーションに富んでいます。
df_abbr <- df_tidying %>% filter(stringr::str_detect(street, "\u301c")) %>% filter(stringr::str_detect(street, "、|−|及び", negate = TRUE)) %>% filter(stringr::str_detect(street, ".+\u301c.+\u301c", negate = TRUE)) %>% select(jis_code, zip_code, prefecture, city, street, rowid)
多様なパターンに対応するため、次のようなコードを書きました、がまだ途中です。
df_abbr %>% separate_street_rows(street, pattern = "丁目", split_chr = "\u301c", suffix = "丁目")
## # A tibble: 982 x 6
## jis_code zip_code prefecture city street rowid
## <chr> <chr> <chr> <chr> <chr> <int>
## 1 01102 0010010 北海道 札幌市北区 北十条西1丁目 2
## 2 01102 0010010 北海道 札幌市北区 北十条西2丁目 2
## 3 01102 0010010 北海道 札幌市北区 北十条西3丁目 2
## 4 01102 0010010 北海道 札幌市北区 北十条西4丁目 2
## 5 01102 0010011 北海道 札幌市北区 北十一条西1丁目 3
## 6 01102 0010011 北海道 札幌市北区 北十一条西2丁目 3
## 7 01102 0010011 北海道 札幌市北区 北十一条西3丁目 3
## 8 01102 0010011 北海道 札幌市北区 北十一条西4丁目 3
## 9 01102 0010012 北海道 札幌市北区 北十二条西1丁目 4
## 10 01102 0010012 北海道 札幌市北区 北十二条西2丁目 4
## # … with 972 more rows
df_abbr %>% filter(stringr::str_detect(street, "[0-9]{1,}(丁目|の).+番地", negate = TRUE) & stringr::str_detect(street, "番地")) %>% separate_street_rows(street, pattern = "番地", split_chr = "\u301c", suffix = "番地")
## # A tibble: 30,224 x 6
## jis_code zip_code prefecture city street rowid
## <chr> <chr> <chr> <chr> <chr> <int>
## 1 01106 0050865 北海道 札幌市南区 常盤1番地 368
## 2 01106 0050865 北海道 札幌市南区 常盤2番地 368
## 3 01106 0050865 北海道 札幌市南区 常盤3番地 368
## 4 01106 0050865 北海道 札幌市南区 常盤4番地 368
## 5 01106 0050865 北海道 札幌市南区 常盤5番地 368
## 6 01106 0050865 北海道 札幌市南区 常盤6番地 368
## 7 01106 0050865 北海道 札幌市南区 常盤7番地 368
## 8 01106 0050865 北海道 札幌市南区 常盤8番地 368
## 9 01106 0050865 北海道 札幌市南区 常盤9番地 368
## 10 01106 0050865 北海道 札幌市南区 常盤10番地 368
## # … with 30,214 more rows
残った課題
ここまで、悪名高い郵便番号データと戦ってきましたが、現在相手にリードされた状況です…。いくつかの課題については対策できたと思いますがまだ満足できていません。もう少し改善が必要です。具体的には以下の3点をどうにかしないといけません。
特に3つめ「存在しない住所を除く」は手を焼きそうです。最後の処理として「〜」で省略された住所を復元しましたが一方で現実には存在しない住所を生み出してしまった可能性があります。例えば「北海道札幌市南区常盤」に「58番地」は存在しますが、それより小さな「50番地」は存在しません。これは、国内の街区レベル、大字・町丁目レベルの住所を記録した国土数値情報の位置参照情報ダウンロードサービス提供のデータにもありませんし、ゼンリンの住所データや東京大学空間情報科学研究センターのジオコーディングサービスにも含まれません。
# 位置参照情報ダウンロードサービス 街区レベルを格納したデータフレーム # 50番地は存在しない[f:id:u_ribo:20191222101835p:plain] df_isj_a %>% filter(prefecture == "北海道", str_detect(city, "札幌市南区"), str_detect(street_lv1, "^常盤$")) %>% pull(street_lv3)
## [1] "102" "109" "110" "110" "118" "118" "119" "122" "125" "126" "126" "126" "127" "128" "129" "130" "131" "132" "133" "133" "134" "135" "136" "137" "138" "139" ## [27] "140" "141" "142" "143" "144" "144" "144" "145" "145" "146" "150" "151" "153" "153" "154" "156" "157" "158" "158" "159" "160" "162" "163" "164" "165" "166" ## [53] "167" "200" "348" "349" "351" "352" "353" "354" "355" "356" "356" "356" "357" "358" "359" "360" "361" "361" "363" "364" "365" "366" "367" "368" "369" "370" ## [79] "371" "372" "373" "374" "376" "377" "378" "381" "383" "384" "385" "386" "387" "388" "389" "390" "391" "392" "393" "399" "400" "401" "402" "403" "404" "412" ## [105] "431" "432" "436" "439" "526" "527" "528" "529" "530" "532" "533" "56" "57" "57" "58" "60"
未完の状態で終わるため俺たたエンドです。これらの問題を解決できた時にまたお会いしましょう!アドバイスがあればください。
俺たちの戦いはこれからだ!
-
効果的なデータ分析を行いやすくするためのデータの持ち方を指す概念。参考: https://www.jstatsoft.org/article/view/v059i10↩