総務省「住民基本台帳人口移動報告」の都道府県×都道府県の移動者数を、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))
色を図どうしでそろえる
コミュニティの番号は分け方ごとに意味がちがうので、そのまま色にすると、解像度や年を変えた図どうしで色がばらばらになります。北の都道府県から順に色を割り当てる関数を挟みました。
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年の人口を近似で使っています
参考
- note(分析の結果と読み方):https://note.com/takayukimatsuura/n/n48da57ccedf4
- GitHub(コード・整形済みデータ・結果):https://github.com/takayuki-0127/japan-open-data-notes/tree/main/articles/F10_mobility_network
- 出典:総務省「住民基本台帳人口移動報告」、総務省「令和2年国勢調査 従業地・通学地による人口・就業状態等集計」、統計センター「SSDSE-B 2026」、国土交通省「国土数値情報(行政区域データ)」をもとにした smartnews-smri/japan-topography(CC BY 4.0)。いずれも加工して作成。

