0
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?

都道府県間の引っ越しを Python でネットワークにする:PageRank と Leiden 法でコミュニティを安定させる実装

0
Posted at

総務省「住民基本台帳人口移動報告」の都道府県×都道府県の移動者数を、Python でネットワークにして、中心性(PageRank)とコミュニティ検出(Leiden 法)をかけるまでの実装をまとめます。分析の結論(日本は4つの圏に分かれ、30年でほとんど変わらなかった)は note に書いたので、この記事は「どう実装したか」に絞ります。

確かめたかったことは2つです。

  • 引っ越しのつながりで見ると、日本はいくつのまとまり(圏)に分かれるか
  • その分け方は、乱数・設定・年を変えても同じか

使ったライブラリは pandas・NumPy・NetworkX(PageRank)・python-igraph と leidenalg(コミュニティ検出)・scikit-learn(分け方どうしの比較)・GeoPandas(地図)です。

1. データの取り方

e-Stat の表は、ブラウザで statInfId を開いて Excel を保存する手作業にしました。取得スクリプトの冒頭に、使った表の番号をすべて書いています。

"""データの取得。

e-Stat のファイルは、ブラウザで次の statInfId を開いて Excel を保存し、data/raw/ に置く
(この記事では、ブラウザ上で Excel を CSV にして 1 つの JSON / CSV にまとめたものを data/raw/ に置いている)。

1) 住民基本台帳人口移動報告(総務省)
   - 長期時系列表 第11表「移動前の住所地別転入者数及び移動後の住所地別転出者数」
     1990年(平成2年) statInfId=000014898778
     2000年(平成12年)statInfId=000014898788
     2010年(平成22年)statInfId=000014898798
   - 年報(実数)2025年 表2「移動前の住所地、男女別都道府県間移動者数」 statInfId=000040407039
"""

地図の境界だけは、国土数値情報をもとにした簡略版(smartnews-smri/japan-topography、CC BY 4.0)をスクリプトから取っています。

url = ("https://raw.githubusercontent.com/smartnews-smri/japan-topography/main/"
       "data/municipality/topojson/s0010/N03-21_210101.json")
dst = RAW / "N03-21_210101.json"
if not dst.exists():
    urllib.request.urlretrieve(url, dst)

2. 47×47 の行列に整形する

長期時系列表(1990・2000・2010年)

長期時系列表は、5行目に都道府県コード(2桁)の見出しがあり、6行目以降が「移動前の都道府県」ごとの行です。見出しのうち2桁の数字の列だけを拾って、行列に入れていきます。

def num(c):
    c = c.strip().replace(",", "")
    return 0 if c in ("", "-", "-", "…", "x", "X") else int(float(c))

for y in ["1990", "2000", "2010"]:
    rows = list(csv.reader(io.StringIO(raw[y]["Sheet1"])))
    hdr = rows[4]
    cols = {j: int(c) for j, c in enumerate(hdr) if re.fullmatch(r"\d\d", c.strip())}
    M = np.zeros((47, 47), int); seen = set(); tot = {}
    for r in rows[5:]:
        c = r[0].strip()
        if re.fullmatch(r"\d\d", c) and 1 <= int(c) <= 47 and int(c) not in seen:
            o = int(c); seen.add(o); tot[o] = num(r[3])
            for j, d in cols.items():
                if 1 <= d <= 47:
                    M[o - 1, d - 1] = num(r[j])
    assert len(seen) == 47 and all(M[o - 1].sum() == tot[o] for o in tot), y
    mats[y] = M

ポイントは最後の assert です。行ごとに、表に載っている「総数」の列(r[3])と、47都道府県に振り分けた値の合計が一致するかを確かめています。列の拾い漏れや、記号(「-」「…」)を数字にし損ねると、ここで止まります。

年報(2025年)

2025年の年報は表の形がちがい、都道府県名の見出しの下に「総数・男・女」が並びます。さらに「日本人移動者」と「移動者(外国人を含む)」が同じ表に入っています。

rows = list(csv.reader(io.StringIO(raw["2025"]["a002"])))
names = rows[4]; kind = rows[5]
colmap = {j: PREF.index(n) for j, n in enumerate(names) if n in PREF and kind[j] == "総数"}
for label, key in [("日本人移動者", "2025"), ("移動者", "2025all")]:
    M = np.zeros((47, 47), int)
    for r in rows[7:]:
        if len(r) > 6 and r[2] == label and r[6] in PREF:
            o = PREF.index(r[6])
            for j, d in colmap.items():
                M[o, d] = num(r[j])
    np.fill_diagonal(M, 0)
    mats[key] = M

1990〜2010年の長期時系列表は日本人だけの集計なので、主な結果は2025年も「日本人移動者」でそろえ、外国人を含む行列は頑健性の確認用に別に保存しました。整形した行列は migration_YYYY.csv(行=移動前、列=移動後)として書き出しています。

3. 中心性:向きつきの PageRank

PageRank(流れの中で「集まる先」としてどれだけ重要かを表す指標)は、向きと重みを残したまま NetworkX で計算しました。

for y in YEARS:
    M = mig[y].values.astype(float)
    G = nx.DiGraph()
    for i, a in enumerate(PREF):
        for j, b in enumerate(PREF):
            if i != j and M[i, j] > 0:
                G.add_edge(a, b, weight=M[i, j])
    pr = nx.pagerank(G, weight="weight")

東京都の PageRank は 0.131(1990年)から 0.159(2025年)に上がりました。「東京圏以外から出た移動のうち東京圏へ向かう割合」も、行列のスライスで出しています。

TK = [PREF.index(p) for p in ["埼玉県", "千葉県", "東京都", "神奈川県"]]
share = {}
for y in YEARS:
    M = mig[y].values
    others = [i for i in range(47) if i not in TK]
    share[y] = {"to_tokyo_area_share": float(M[np.ix_(others, TK)].sum() / M[others, :].sum()),
                "total": int(M.sum())}

結果は 30.4%(1990年)→32.9%(2025年)でした。

4. コミュニティ検出:Leiden 法を「何度も回して多数決」

グラフを作る

コミュニティ検出は向きなしで行いました。行き(i→j)と帰り(j→i)を足した対称行列にして、igraph のグラフにします。

def sym(M):
    A = M + M.T
    np.fill_diagonal(A, 0)
    return A.astype(float)

def graph(W, names):
    iu = np.triu_indices_from(W, 1)
    edges = [(i, j) for i, j in zip(*iu) if W[i, j] > 0]
    g = ig.Graph(n=len(names), edges=edges)
    g.vs["name"] = names
    g.es["weight"] = [W[i, j] for i, j in edges]
    return g

Leiden 法を呼ぶ

leidenalg の RBConfigurationVertexPartition を使うと、解像度(どこまで細かく分けるか)を指定できます。

def leiden(g, res, seed):
    p = la.find_partition(g, la.RBConfigurationVertexPartition, weights="weight",
                          resolution_parameter=res, seed=seed, n_iterations=-1)
    return np.array(p.membership), g.modularity(p.membership, weights="weight")

n_iterations=-1 は、分け方が変わらなくなるまで繰り返す指定です。

番号をそろえて多数決を取る

Leiden 法の結果は乱数の種で変わります。そこで種を100通り変えて回し、いちばん多く出た分け方を採用しました。このとき、同じ分け方でもコミュニティの番号(0, 1, 2…)の振り方が回ごとにちがうので、そのまま数えると別物として扱われてしまいます。canon で「最初に出てきた順」に番号を振り直してから数えています。

def canon(m):
    # 番号の付け方をそろえる(最初に出てきた順)
    mp = {}; out = []
    for x in m:
        mp.setdefault(x, len(mp)); out.append(mp[x])
    return tuple(out)

def stable(g, res, n=N_SEEDS):
    parts = [canon(leiden(g, res, SEED + s)[0]) for s in range(n)]
    cnt = Counter(parts)
    best, freq = cnt.most_common(1)[0]
    # 同じ組になった割合(共起行列)
    P = np.array(parts); k = P.shape[1]
    co = np.zeros((k, k))
    for p in P:
        co += (p[:, None] == p[None, :])
    co /= len(P)
    q = g.modularity(list(best), weights="weight")
    return np.array(best), freq / n, co, q

co は「2つの都道府県が同じコミュニティに入った回の割合」です。分け方の多数決(freq)だけでなく、ペアごとに安定しているかも見られるようにしました。2025年・解像度1では、100通り中97通りが同じ分け方でした。

解像度 × 正規化 × 年 のグリッド

あとはループで、解像度7通り・正規化3通り・4年分を回します。

RESOS = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0]
for y in YEARS:
    A = sym(mig[y].values)
    popv = pop["jp2012"].values if y in ("1990", "2000", "2010") else pop["jp2023"].values
    for how in ["count", "lift", "pop"]:
        if how == "pop" and y in ("1990", "2000"):
            continue  # 人口の表がないので 2010・2025 だけ
        W = normalize(A, how, popv)
        g = graph(W, PREF)
        for r in RESOS:
            m, freq, co, q = stable(g, r)

正規化は「件数のまま」「出し入れの大きさから期待される量との比」「人口の積で割る」の3つです。

def normalize(A, how, popv=None):
    if how == "count":
        return A
    if how == "lift":  # 出し入れの大きさで期待される量との比
        s = A.sum(1); S = s.sum()
        E = np.outer(s, s) / S
        return np.where(E > 0, A / E, 0)
    if how == "pop":  # 人口の積で割る(人口あたりの結びつき)
        P = np.outer(popv, popv)
        B = A / P
        return B / B.sum() * A.sum()
    raise ValueError(how)

pop の最後で A.sum() に合わせて全体を伸ばしているのは、重みの合計の大きさを件数のときとそろえるためです(推測:解像度の効き方を正規化どうしで比べやすくする意図)。

5. 分け方どうしを比べる:ARI と NMI

年どうし・正規化どうしの「分け方の一致」は、scikit-learn の ARI(調整ランド指数、1で完全一致)と NMI(正規化相互情報量)で測りました。番号の振り方に依存しない指標なので、canon の結果をそのまま渡せます。

from sklearn.metrics import adjusted_rand_score as ARI, normalized_mutual_info_score as NMI

cmp.append({"what": "year", "a": a, "b": b, "res": r, "ARI": ARI(parts[(a, "count", r)], parts[(b, "count", r)]),
            "NMI": NMI(parts[(a, "count", r)], parts[(b, "count", r)])})

教科書の8地方区分とも、同じ指標で比べています。8地方区分はラベルの配列を直接書きました。

BLOCK = [0]*1 + [1]*6 + [2]*7 + [3]*9 + [4]*7 + [5]*5 + [6]*4 + [7]*8

1990年と2025年の一致は ARI 0.89、8地方区分との一致は解像度1.5で NMI 0.87 が最大でした。

6. 図の出し方

ネットワークの図

地図の上に、行き来の多い上位70組を LineCollection で線として重ね、点の大きさを PageRank にしました。

iu = np.triu_indices(47, 1)
vals = A[iu]; order = np.argsort(vals)[::-1][:70]
segs, ws = [], []
for k in order:
    i, j = iu[0][k], iu[1][k]
    segs.append([(pxy.iloc[i].lon, pxy.iloc[i].lat), (pxy.iloc[j].lon, pxy.iloc[j].lat)]); ws.append(vals[k])
ws = np.array(ws)
ax.add_collection(LineCollection(segs, linewidths=0.4 + 5 * ws / ws.max(), colors="#333", alpha=0.45))

2025年の移動ネットワーク

色を図どうしでそろえる

コミュニティの番号は分け方ごとに意味がちがうので、そのまま色にすると、解像度や年を変えた図どうしで色がばらばらになります。北の都道府県から順に色を割り当てる関数を挟みました。

def recolor(m):
    # 北から順に色を割り当てる(色の対応を図どうしでそろえる)
    order = []
    for p in PREF:
        if m[p] not in order:
            order.append(m[p])
    return {p: PAL[order.index(m[p]) % len(PAL)] for p in PREF}

沖縄は translate で左上に寄せて表示しています。

def okinawa_shift(gdf):
    # 沖縄を左上に寄せて表示
    g = gdf.copy()
    m = g.index == "沖縄県"
    g.loc[m, "geometry"] = g.loc[m, "geometry"].translate(xoff=5.5, yoff=15.5)
    return g

解像度を変えたコミュニティ

7. つまずいた点と落とし穴

  • 表の形が年でちがう:長期時系列表と年報では、見出しの位置も「総数・男・女」の並びもちがいます。年ごとに読み方を分け、行の合計と総数の一致を assert で確かめるようにしました。
  • 日本人だけか、外国人を含むか:長期時系列表は日本人だけの集計です。2025年を「移動者」のまま使うと、年どうしの比較に集計範囲の差が混ざります。2025年は日本人移動者でそろえ、外国人を含む版は頑健性に回しました(含めると ARI 0.95 で、山口の所属が入れ替わりました)。
  • Leiden 法は1回の結果を信じない:種を変えると分け方が変わります。1990年・解像度1では、100通りのうち同じ分け方は76通りでした。番号をそろえて多数決を取り、共起行列で境目の県を確かめるところまでが1セットです。
  • コミュニティの番号は意味を持たない:比較には ARI・NMI を、図には色の振り直しを使います。
  • 解像度で「いくつ」は変わる:2025年は解像度0.75で2つ、1.0で4つ、1.5で8つ、2.0で9つでした。1つの解像度の結果だけを「答え」にしないよう、グリッドで回して表にしています。
  • 正規化で結果が動く:件数のままだと大きな都道府県の影響が強く出ます。期待値との比や人口の積で割ると、近畿が中部と同じ圏になり、静岡は「東」に入りました(件数との ARI 0.71)。

8. 言えないこと

  • 引っ越しの理由(進学・就職・結婚・住宅)は、このデータからは分かりません
  • 圏の数は解像度という設定しだいで、「日本は4つ」が唯一の答えではありません
  • 人口で割る正規化は、2010年に2012年、2025年に2023年の人口を近似で使っています

参考

0
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
0
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?