🙆

市区町村の「代表点」に悩んでみた話

に公開

市区町村の「代表点」をどう決めるか — 4手法を比較実装した話

はじめまして、キンパッツです!
ネクストビートという会社でサイエンスマネージャーをやっています。

ナナコっていうセブンイレブン系の電子マネーあるじゃないですか。
僕もよく使うんですが、近所のドラッグストアで会計時に「ナナコでお願いします」と言うと必ず「はい! ナナコさんで承りますね」とさん付けで返してくれる店員さんがいるんですね。
確かにナナコ、キャラクターっぽい名前だし、さんづけは可愛らしくていいなあと思っていました。
僕もこれから「ナナコさんでお願いします!」って言おうと。

なんですが、この前気まぐれに「PayPayで!」とお願いしたら「PayPayさんですね。バーコードをお願いします」と返ってきて虚を突かれました。人名っぽいサービスをさんづけしてるんじゃなくて、電子決済全般を擬人化してる人だったんだー、と。

閑話休題。はじめます。

はじめに

市場分析をしていると、「対象地域のデータを近隣の自治体と比較したい」という場面、よくありますよね。競合状況や人口動態って、その自治体単体で見るのは当然として、やはり近くの自治体と並べたほうが説得力が出る。

で、いきなり詰まったのが 「近隣」をどう定義するか という問題でした。

隣接してればよいの? 20km以内? 30分以内? 距離や到達時間で定義するとしても、市区町村間の距離ってどうやって測ればいいんでしょうか。市区町村はポリゴンなので、そのままでは「AとBの距離」は出せない。どこか1点で代表させる必要があります。

「近隣」定義の前に、「代表点を決定」しないといけない。なんてことだ。

「市区町村の座標」なんて普通によく使いそうなものですが、意外に考え出すとややこしくて、試行錯誤してみました。

今回実験したのは、結果的に以下の4つです。コードは全部 shapely なしの素の Python です。
※ コード自体はすべて Claude Code に書いてもらっています。ロジックの意図を伝えて実装は完全にお任せしているので、「こう書いた方がいい」「ここ間違ってる」などあれば遠慮なくコメントください。

  • 面積加重重心 — ポリゴンの幾何学的な中心
  • 人口加重重心 — 人が住んでいる場所の重心
  • 役所・役場座標 — 行政の中心点
  • 乗降客数加重重心 — 交通活動の重心

手法1: 面積加重重心

一番直感的な手法です。図形的な中心を求める。
具体的には、国土交通省の 国土数値情報「行政区域データ(N03)」 のGeoJSONから市区町村のポリゴンを読み込んで、面積加重平均で重心を求めました。データはここから全国版のZIPをダウンロードして解凍するだけです。

面積の計算には Shoelace公式(靴ひも公式、ガウスの面積公式)を使い、結果を重心計算に利用します。外部ライブラリなしで面積が出るのが便利。
ざっくりとしたイメージは、領域を三角形で分割して各サブ領域の面積と重心を計算し、最後に面積で加重平均をして全体の中心(重心)を出すという感じです。

def _shoelace_area(ring):
    n = len(ring)
    area = 0.0
    for i in range(n):
        x1, y1 = ring[i]
        x2, y2 = ring[(i + 1) % n]
        area += x1 * y2 - x2 * y1
    return abs(area) / 2.0

def calc_centroid(rings):
    """複数リング(飛び地・離島対応)の面積加重重心"""
    total_area = 0.0
    w_lat = w_lng = 0.0
    for ring in rings:
        area = _shoelace_area(ring)
        cx = sum(p[0] for p in ring) / len(ring)
        cy = sum(p[1] for p in ring) / len(ring)
        w_lat += area * cy
        w_lng += area * cx
        total_area += area
    return w_lat / total_area, w_lng / total_area

政令指定都市の扱いがちょっと厄介で、GeoJSONは区単位でフィーチャーが入っています。N03_005(行政区名)がある場合は 区レベル市レベル(全区集約) の両方をキャッシュして、どちらのキーでも引けるようにしました。

"大阪市" → 全区ポリゴンを合算した重心
"中央区"(大阪府)→ 中央区単独の重心

この手法の弱点は、地形的に広い自治体で山林・過疎地に重心が引っ張られること。

自治体 傾向
渋谷区 コンパクトなので問題なし
八王子市 西部の高尾山・陣馬山エリアが重心を西に引く
奈良市 市域の大部分が山林 → 重心が山側に
京都市 北部の広大な山岳エリアが重心を北に引く

全件カバーできる(離島・山村含め全市区町村で取れる)のは強みです。


手法2: 人口加重重心

各自治体の実態に近い手法を考えていきましょう。ぱっと思いつくのは人がたくさん住んでるところを代表点にする、という考え方。実際に試してみます。
国土数値情報の 500mメッシュ人口データ を重みにして、人が住んでいる場所に引き寄せた重心を計算します。人口メッシュデータ、他にも地理的な分析に大活躍しており、すごくありがたい。
データはこちら。9桁の4次メッシュコード(約500m四方)から座標を復元するところが実装の肝です。

def mesh4_to_latlon(code: str):
    """9桁4次メッシュコード → 中心緯度経度"""
    pp = int(code[0:2])
    qq = int(code[2:4])
    r, s, u, v = int(code[4]), int(code[5]), int(code[6]), int(code[7])
    w = int(code[8])  # 1=SW, 2=SE, 3=NW, 4=NE

    lat = pp / 1.5 + r / 12 + u / 120
    lng = 100 + qq + s / 8 + v / 80

    sub_row = (w - 1) // 2
    sub_col = (w - 1) % 2
    lat += sub_row / 240 + 1 / 480
    lng += sub_col / 160 + 1 / 320
    return lat, lng

重心の計算自体はシンプルで、人口を重みにして加重平均するだけ。

for row in mesh_rows:
    lat, lng = mesh4_to_latlon(row["MESH_ID"])
    pop = row["POP2025"]
    for key in code_to_name[row["CITY_CODE"]]:
        accum[key]["w_lat"]     += pop * lat
        accum[key]["w_lng"]     += pop * lng
        accum[key]["total_pop"] += pop

centroid_lat = accum[key]["w_lat"] / accum[key]["total_pop"]

面積重心と比べるとズレがよくわかります。

自治体 面積重心との差 備考
渋谷区 約320m 市街地でも数百mのズレは出る
八王子市 約4km 高尾山側に引かれていた重心が市街地寄りに
奈良市 約9km 山林と市街地の乖離が大きい
京都市 約12km 北部山岳エリアの影響がかなり大きい

通勤・居住圏の実態に近い距離を出したいなら、この手法が一番しっくりきます。
結論、この手法を使うのがよさそうでした。

京都市(約12kmのズレ) 奈良市(約9kmのズレ)
京都市 奈良市

灰が面積加重重心、青が人口加重重心。山林が広い自治体ほどズレが大きい。


手法3: 役所・役場座標

自治体の中心は役所だよね、という、こちらもシンプルな発想。
OpenStreetMap(Overpass API) から amenity=townhall タグの施設を取得して、市区町村と紐付けます。都道府県単位でクエリを投げて結果をJSONキャッシュしておけば(47クエリ、1回きり)、あとはローカルで処理できます。

def fetch_townhalls(pref_bbox):
    query = f"""
    [out:json];
    node["amenity"="townhall"]({pref_bbox});
    out body;
    """
    resp = requests.get("https://overpass-api.de/api/interpreter",
                        params={"data": query}, timeout=30)
    return resp.json()["elements"]

ちょっと工夫が必要なのが 名前マッチング です。OSMの名称(例: 「名古屋市中区役所」)から「役所」「役場」を切り取って市区町村名と照合します。出張所・分庁舎は除外。

SUFFIXES   = ["役所", "役場"]
SKIP_WORDS = ["出張所", "分庁舎", "支所", "連絡所"]

def strip_suffix(name):
    name = re.sub(r'\s*[\((].*?[\))]', '', name).strip()  # 括弧内注釈を除去
    if any(w in name for w in SKIP_WORDS):
        return None
    for suf in SUFFIXES:
        idx = name.find(suf)
        if idx != -1:
            return name[:idx]
    return None

「行政の中心点」として対外的に説明しやすいのが利点。それ以上の意味があるかというと、正直なところどうでしょうか…。

補足として、OSMの登録漏れがある自治体は no_data になる(全国で約763件)ので、全件カバーは難しいです。
後述しますが、奈良市などは取れていませんでした。

正直なところ、役所の位置特定にはもっとシンプルなやり方や良いデータがあると思っているので、ご存じな方は教えてください。


手法4: 乗降客数加重重心

交通データも使えないかな、ということで、鉄道データも探してみました。見つけたのが以下。
国土数値情報の S12「駅別乗降客数データ」 で、市区町村内の各駅を乗降客数で重み付けした重心を計算します。「交通活動の中心」とでも言えばいい指標です。
データはこちら。S12はジオメトリが LineString(駅区間)なので、中点を駅座標として使います。

※ 最大乗降客数の駅のみをそのまま使う方法も試しましたが、本記事では割愛します。

def linestring_midpoint(coords):
    mid = len(coords) // 2
    return coords[mid][1], coords[mid][0]  # lat, lng

結果を見ると、人口重心とはまた違う傾向が出て面白いです。

市区町村 最大駅 特徴
渋谷区 新宿 隣接する新宿駅が渋谷区判定 → 重心が北東に引かれている
八王子市 八王子 人口重心とほぼ同位置
京都市 京都 人口重心とほぼ同位置(ただし観光客の乗降も大量に含まれている)
奈良市 近鉄奈良 人口重心とほぼ同位置(わずかに東)

そういえば新宿駅が区境界にあるっていうのは有名な話でしたね。

境界またぎのケースだけでなく、市区町村への駅割り当てに bounding box を使っているため、境界付近の駅が隣の自治体に誤割り当てされることもあります。東京23区みたいに小さな区が隣接するケースで顕著です。精度を上げるには shapely 等でポイントインポリゴン判定をするのが正攻法ですが、今回は割り切りました。あと、そもそも駅がない自治体はこの手法では代表点が出ません(約713件が no_data)。

そして、よくよく考えるとこの手法、昼間人口と夜間人口のあいのこみたいな値になるんですよね。通勤で流入する人も、住んでいる人も、どちらも駅を使う。都心部では昼間人口が大きいので、夜間の居住実態からはズレてきます。実際、都市部でいくつか試してみたら直感と合わない結果が出ました。

観光地ではさらにひと癖あって、例えば京都駅は年間数千万人規模の観光客が通過します。乗降客数には当然その分も上乗せされるので、「居住者 + 通勤者 + 観光客」の合算になってしまう。観光地ほどこの手法の値は実態の居住圏から離れていく、という点は頭に入れておく必要があります。

商圏分析や来街者の文脈では使えるかもしれませんが、居住圏ベースの分析には向いていないかもしれません。ベッドタウンや郊外の場合も、路線から離れた場所にもそれなりの人口がいるので、分析の意図と合っているかよく考えてから使う必要がありそうですね。


4手法まとめ

手法 データソース カバー率 向いている用途
面積加重重心 国土数値情報 N03 全市区町村 とにかく全件欲しい
人口加重重心 500mメッシュ人口 約1,891件 通勤・居住圏の距離感
役所座標 OpenStreetMap 約1,157件 行政・公的な文脈
乗降客数加重重心 国土数値情報 S12 約1,207件 交通アクセスの文脈

4自治体で実際にプロットしてみると、手法の違いが視覚的によくわかります。

渋谷区 八王子市
渋谷区 八王子市
区自体が小さく、コンパクトな区域に4点が密集 面積重心(灰)だけ高尾山方向に引かれる。役所の位置も少し外れている?
京都市 奈良市
京都市 奈良市
北部山岳エリアが広く、面積重心が大きく北に 役所データなし。人口重心と乗降客数重心が近い

追加で、「わかりやすく4点がばらけてる市区町村ない?」とClaude Codeさんに訊いて出てきたのが函館市です。
函館市は2004年の大合併で北東方向に市域が大きく広がりました、とのこと。

函館市
函館市
面積重心(灰)が北東の旧合併エリアに引かれ、他3点は市街地付近に集まるがバラつく

いま進めているタスクでは、人口加重重心をメインに使って、人口データがない自治体は面積重心でフォールバックする構成にすることにしました。


おわりに

小ネタでしたが、地理情報を扱う機会があればぜひ参考にしてもらえると嬉しいです。

「市区町村の座標」という一見シンプルな問いが、掘り下げると結構奥深くて、頭の体操になりました。
国土数値情報は色々なデータが揃っていて、今回初めてちゃんと触りましたが、ありがたいリソースだなと改めて感じているところです。
そして、AIの力でいろんな手法の実装がすぐにできてしまい、また可視化までサクッとやってしまえるのも、改めてすごいことだなと。

また、計算した代表点がズレるということは、なんらかの需給ギャップを示唆しているのでしょう。深掘りしていくとお金になりそう面白そうですね。

以上です。キンパッツでした。

nextbeat Tech Blog

Discussion