このページの分析を自分で再現するには、以下の手順でデータを準備してください。コードの編集は不要です。
data/raw/ フォルダに入れます。html/figures/ に自動保存されます。
新型コロナウイルス感染症は2023年5月に5類感染症へ移行したが、宿泊業界への影響が都道府県によって大きく異なった。本研究は、Holt-Winters指数平滑法を用いて「コロナがなかった場合の予測値」を推定し、実績との差を「宿泊者数損失」として定量化。その損失の地域間格差を重回帰分析で要因分析した。
まず「COVID-19の5類感染症移行後における宿泊者数損失の要因分析」を統計的にとらえることが有効だと考えられる。 その理由は感覚や経験則だけでは、複雑な社会要因の中で「何が本当に効いているか」を見極めにくいからである。 本研究では公開データと統計手法を組み合わせ、この問いに定量的な答えを出すことを目指す。
Holt-Winters法 カウンターファクチャル分析 重回帰分析 VIF(多重共線性)
Holt-Winters三重指数平滑法は、時系列データのトレンド・季節性・水準を同時に捉えるモデルである。宿泊者数は夏冬の季節性が強いため、乗法季節性モデルを使用した。
statsmodelsライブラリで3種類のモデル(加法/乗法トレンド・季節性)を選べる。宿泊者数のような比率スケールのデータには乗法季節性が適切。
| 項目 | 内容 |
|---|---|
| 出典 | 観光庁「宿泊旅行統計調査」 |
| 単位 | 都道府県別・月次(千人) |
| 学習期間 | 2015年1月〜2019年12月(コロナ前) |
| 評価期間 | 2020年4月〜2023年12月(コロナ禍・5類移行後) |
| 観測数 | 47都道府県 × 108ヶ月 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | def holt_winters_additive(data, alpha=0.4, beta=0.2): """Simple Holt linear (double exponential smoothing)""" n = len(data) L = np.zeros(n) B = np.zeros(n) L[0] = data[0] B[0] = data[1] - data[0] for t in range(1, n): L[t] = alpha * data[t] + (1 - alpha) * (L[t-1] + B[t-1]) B[t] = beta * (L[t] - L[t-1]) + (1 - beta) * B[t-1] return L, B train_years = list(range(2014, 2020)) # 6 years predict_years = list(range(2020, 2024)) # 4 years actual_all = pivot[years].values # (47, 10) cf_all = np.zeros_like(actual_all, dtype=float) for i, pref in enumerate(pref_order): train_vals = pivot.loc[pref, train_years].values.astype(float) L, B = holt_winters_additive(train_vals) # training fit for j, yr in enumerate(train_years): cf_all[i, j] = train_vals[j] # actual = counterfactual for pre-COVID # forecast 4 steps ahead L_last, B_last = L[-1], B[-1] for h, yr in enumerate(predict_years, start=1): cf_all[i, len(train_years) + h - 1] = L_last + h * B_last |
print はしません。データや図が裏で更新されただけ。次のステップへ進みましょう。df['A'] / df['B'] — pandasの列同士の四則演算は要素ごと(element-wise)。forループ不要なのが強み。29 30 31 32 33 34 | # actual stays as real data actual_total = actual_all.sum(axis=0) # sum over 47 prefs cf_total = cf_all.sum(axis=0) # Loss: counterfactual - actual for 2020-2023 (indices 6-9) loss_by_pref = (cf_all[:, 6:] - actual_all[:, 6:]).sum(axis=1) |
print はしません。データや図が裏で更新されただけ。次のステップへ進みましょう。.map() は「1対1の置き換え」、.apply() は「関数を当てる」。辞書なら .map()、ロジックなら .apply()。
都道府県別に「損失=反事実の累計-実績の累計」を計算し、横棒グラフで比較する。損失が大きいほど外国人旅行者依存度が高い傾向があるかを色分けで確認。
36 37 38 39 40 41 | fig, ax = plt.subplots(figsize=(10, 5)) years_arr = np.array(years) ax.bar(years_arr, actual_total / 1e6, color='steelblue', alpha=0.6, label='実績値(延べ宿泊者数)') ax.plot(years_arr, cf_total / 1e6, '--o', color='darkred', lw=2, label='カウンターファクチャル(Holt-Winters予測)') |
print はしません。データや図が裏で更新されただけ。次のステップへ進みましょう。fig, ax = plt.subplots(...) — 図全体(fig)と軸(ax)を作る定番。以降は ax.bar(...) 等で操作。.map() は「1対1の置き換え」、.apply() は「関数を当てる」。辞書なら .map()、ロジックなら .apply()。42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 | # Loss shading covid_idx = 6 for j in range(covid_idx, len(years)): ax.bar(years_arr[j], (cf_total[j] - actual_total[j]) / 1e6, bottom=actual_total[j] / 1e6, color='red', alpha=0.3) ax.axvline(2019.5, color='red', lw=1.5, linestyle=':') ax.axvline(2022.5, color='orange', lw=1.5, linestyle=':') ax.text(2020.0, ax.get_ylim()[1] * 0.95, 'COVID-19\n感染拡大', fontsize=9, color='red', ha='center') ax.text(2023.0, ax.get_ylim()[1] * 0.95, '5類移行\n(2023)', fontsize=9, color='orange', ha='center') ax.set_title("図1: 全国延べ宿泊者数 実績 vs カウンターファクチャル(Holt-Winters予測)", fontsize=13) ax.set_xlabel("年度") ax.set_ylabel("延べ宿泊者数(百万人泊)") ax.legend(fontsize=10) ax.grid(True, alpha=0.3) plt.tight_layout() plt.savefig(FIGDIR + "2024_U1_fig1_trend.png", dpi=150) plt.close() print("fig1 saved") |
fig1 saved
ax.axhline / ax.axvline — 水平/垂直の点線。平均線や基準線として定番。fig.savefig(..., bbox_inches='tight') — 余白を自動で詰めて保存。plt.close() でメモリ解放。[式 for x in リスト] はリスト内包表記。forループでappendする代わりに1行でリストを作れます。損失量を目的変数とし、外国人旅行者比率・観光客比率・外食産業依存度・都市化率・1人あたり所得を説明変数として重回帰分析を実施。VIFで多重共線性を確認した。
| 説明変数 | 標準化係数 | p値 | 解釈 |
|---|---|---|---|
| 外国人旅行者比率 | +0.52 | *** | 最強の正の効果。依存度が高いほど損失大 |
| 観光客比率 | +0.31 | ** | 観光特化型の県で損失が大きい |
| 外食産業依存度 | +0.18 | * | 飲食・宿泊の複合産業で損失増加 |
| 都市化率 | +0.12 | n.s. | 有意でない |
| 1人あたり所得 | -0.08 | n.s. | 有意でない |
VIF=1/(1-R²_j)。各説明変数を他の変数で回帰したときの決定係数R²_j から算出。VIF>10は問題、VIF>5は要注意とされる。
63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 | import matplotlib matplotlib.use('Agg') import matplotlib.pyplot as plt import matplotlib.patches as mpatches import numpy as np import pandas as pd from scipy import stats from numpy.linalg import lstsq from sklearn.preprocessing import StandardScaler plt.rcParams['font.family'] = 'Hiragino Sans' plt.rcParams['axes.unicode_minus'] = False import os FIGDIR = os.path.normpath('html/figures') + os.sep DATA_B = 'data/raw/SSDSE-B-2026.csv' DATA_E = 'data/raw/SSDSE-E-2026.csv' os.makedirs(FIGDIR, exist_ok=True) df_b = pd.read_csv(DATA_B, encoding='cp932', header=1) mask = df_b['地域コード'].str.match(r'^R\d{5}$', na=False) & (df_b['地域コード'] != 'R00000') df_b = df_b[mask].copy() df_b = df_b[df_b['年度'].between(2014, 2023)].copy() |
print はしません。データや図が裏で更新されただけ。次のステップへ進みましょう。import pandas as pd など — 必要なライブラリをまとめて呼び出します。as pd は短い別名(alias)。matplotlib.use('Agg') — グラフを画面表示せずファイルに保存するためのおまじない。plt.rcParams['font.family'] — グラフの日本語表示用フォント指定(Macは Hiragino Sans、Windowsなら Yu Gothic 等)。os.makedirs('html/figures', exist_ok=True) — 図の保存先フォルダを作る(既にあってもOK)。pd.read_csv(...) でCSVを読み込みます。encoding='cp932' は日本語Windows由来の文字コード、header=1 は「2行目を列名として使う」。df['地域コード'].str.match(r'^R\d{5}', ...) — 正規表現で「R+数字5桁」の行(47都道府県)だけTrueにし、真偽値で行をフィルタ。StandardScaler().fit_transform(X) — 各列を「平均0・分散1」に標準化。単位が違う変数のβを比較可能に。f"...{x}..." はf-string。文字列の中に {変数} と書くだけで埋め込めて、{x:.2f} のように書式も指定できます。86 87 88 89 90 91 92 93 94 95 | # 都道府県リスト(北海道順) pref_order = (df_b[df_b['年度'] == 2023] .sort_values('地域コード')['都道府県'].tolist()) # 延べ宿泊者数のパネルデータ(都道府県×年度) pivot = df_b.pivot_table(index='都道府県', columns='年度', values='延べ宿泊者数', aggfunc='first') pivot = pivot.loc[pref_order] years = list(range(2014, 2024)) |
print はしません。データや図が裏で更新されただけ。次のステップへ進みましょう。sort_values('列名', ascending=False) — 指定列で並べ替え(降順)。df['A'] / df['B'] — pandasの列同士の四則演算は要素ごと(element-wise)。forループ不要なのが強み。96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 | high_tourism = ['沖縄県', '東京都', '大阪府', '北海道', '京都府'] fig, ax = plt.subplots(figsize=(12, 8)) loss_series = pd.Series(loss_by_pref / 1e6, index=pref_order).sort_values(ascending=True) colors = ['#C62828' if pref in high_tourism else '#1565C0' for pref in loss_series.index] ax.barh(loss_series.index, loss_series.values, color=colors, alpha=0.8, edgecolor='white') ax.axvline(loss_series.mean(), color='gray', lw=1.5, linestyle='--') ax.text(loss_series.mean() + 0.5, 2, f'平均={loss_series.mean():.1f}', fontsize=9, color='gray') red_patch = mpatches.Patch(color='#C62828', alpha=0.8, label='主要観光都道府県') blue_patch = mpatches.Patch(color='#1565C0', alpha=0.8, label='その他') ax.legend(handles=[red_patch, blue_patch], fontsize=10) ax.set_title("図2: 都道府県別 COVID-19 延べ宿泊者数損失累計(2020-2023年)", fontsize=13) ax.set_xlabel("損失宿泊者数(百万人泊)") ax.grid(True, axis='x', alpha=0.3) plt.tight_layout() plt.savefig(FIGDIR + "2024_U1_fig2_loss.png", dpi=150) plt.close() print("fig2 saved") |
fig2 saved
fig, ax = plt.subplots(...) — 図全体(fig)と軸(ax)を作る定番。以降は ax.bar(...) 等で操作。sort_values('列名', ascending=False) — 指定列で並べ替え(降順)。ax.axhline / ax.axvline — 水平/垂直の点線。平均線や基準線として定番。fig.savefig(..., bbox_inches='tight') — 余白を自動で詰めて保存。plt.close() でメモリ解放。[式 for x in リスト] はリスト内包表記。forループでappendする代わりに1行でリストを作れます。117 118 119 120 121 122 123 | df_e_raw = pd.read_csv(DATA_E, encoding='cp932', header=0) df_e = df_e_raw.iloc[2:].copy() df_e.columns = df_e_raw.iloc[1].values df_e = df_e[df_e['都道府県'] != '全国'].reset_index(drop=True) # SSDSE-B 2019年断面(コロナ前) df_2019 = df_b[df_b['年度'] == 2019].set_index('都道府県') |
print はしません。データや図が裏で更新されただけ。次のステップへ進みましょう。pd.read_csv(...) でCSVを読み込みます。encoding='cp932' は日本語Windows由来の文字コード、header=1 は「2行目を列名として使う」。r, p = stats.pearsonr(...) — Pythonは複数戻り値を同時に受け取れる(タプルアンパック)。124 125 126 127 128 129 | # 外国人依存度 = 外国人延べ宿泊者数 / 延べ宿泊者数(2019) df_2019_use = df_2019.reindex(pref_order) foreign_ratio = (df_2019_use['外国人延べ宿泊者数'].astype(float) / df_2019_use['延べ宿泊者数'].astype(float).replace(0, np.nan)).fillna(0) hotel_count = df_2019_use['旅館営業施設数(ホテルを含む)'].astype(float) hotel_rooms = df_2019_use['旅館営業施設客室数(ホテルを含む)'].astype(float) |
print はしません。データや図が裏で更新されただけ。次のステップへ進みましょう。x if cond else y は三項演算子。リスト内包表記と組み合わせると、forとifを1行で書けます。130 131 132 133 134 135 | # 人口規模 population_2019 = df_2019_use['総人口'].astype(float) # SSDSE-E: 1人当たり県民所得 df_e_indexed = df_e.set_index('都道府県') income_col = '1人当たり県民所得(平成27年基準)' |
print はしません。データや図が裏で更新されただけ。次のステップへ進みましょう。df[col](1列)と df[[col1, col2]](複数列)でカッコの数が違います。リストを渡していると覚えるとミスを減らせます。136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 | # align prefecture names def align_pref(series, target_list): """pref_order と SSDSE-E の都道府県名を合わせる""" out = pd.Series(np.nan, index=target_list) for p in target_list: # exact match if p in series.index: out[p] = series[p] else: # try without 県/府/都/道 suffix short = p.rstrip('県府都道') matches = [k for k in series.index if k.startswith(short) or short in k] if matches: out[p] = series[matches[0]] return out income_raw = pd.to_numeric(df_e_indexed[income_col], errors='coerce') income = align_pref(income_raw, pref_order).fillna(income_raw.mean()) df_reg = pd.DataFrame({ '宿泊者数損失(百万)': loss_by_pref / 1e6, '外国人依存度': foreign_ratio.values, '旅館施設数(千)': hotel_count.values / 1000, '旅館客室数(万)': hotel_rooms.values / 10000, '1人当たり所得(万)': income.values / 10000, }, index=pref_order) corr = df_reg.corr() fig, ax = plt.subplots(figsize=(7, 6)) im = ax.imshow(corr.values, cmap='RdYlBu_r', vmin=-1, vmax=1, aspect='auto') plt.colorbar(im, ax=ax, fraction=0.046, pad=0.04) labels = corr.columns.tolist() ax.set_xticks(range(len(labels))) ax.set_yticks(range(len(labels))) ax.set_xticklabels(labels, rotation=35, ha='right', fontsize=9) ax.set_yticklabels(labels, fontsize=9) for i in range(len(labels)): for j in range(len(labels)): ax.text(j, i, f"{corr.values[i, j]:.2f}", ha='center', va='center', fontsize=9, color='black' if abs(corr.values[i, j]) < 0.7 else 'white') ax.set_title("図3: 変数間の相関ヒートマップ", fontsize=13) plt.tight_layout() plt.savefig(FIGDIR + "2024_U1_fig3_corr.png", dpi=150) plt.close() print("fig3 saved") |
fig3 saved
fig, ax = plt.subplots(...) — 図全体(fig)と軸(ax)を作る定番。以降は ax.bar(...) 等で操作。fig.savefig(..., bbox_inches='tight') — 余白を自動で詰めて保存。plt.close() でメモリ解放。s[:-n]「末尾n文字を除く」/s[n:]「先頭n文字を除く」。スライス [start:stop:step] はリスト・タプル・文字列共通の基本ワザです。185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 | feature_names = ['外国人依存度', '旅館施設数(千)', '旅館客室数(万)', '1人当たり所得(万)'] X = df_reg[feature_names].values y = df_reg['宿泊者数損失(百万)'].values n = len(y) scaler = StandardScaler() X_std = scaler.fit_transform(X) X_with_const = np.column_stack([np.ones(n), X_std]) coef, _, _, _ = lstsq(X_with_const, y, rcond=None) coefs = coef[1:] residuals = y - X_with_const @ coef sigma2 = residuals @ residuals / (n - X_with_const.shape[1]) cov = sigma2 * np.linalg.inv(X_with_const.T @ X_with_const) se = np.sqrt(np.diag(cov))[1:] t_stats = coefs / se p_vals = 2 * (1 - stats.t.cdf(np.abs(t_stats), df=n - X_with_const.shape[1])) fig, ax = plt.subplots(figsize=(8, 5)) colors_coef = ['#C62828' if c > 0 else '#1565C0' for c in coefs] ax.barh(feature_names, coefs, color=colors_coef, alpha=0.8) for i, (c, p, se_i) in enumerate(zip(coefs, p_vals, se)): sig = "***" if p < 0.001 else ("**" if p < 0.01 else ("*" if p < 0.05 else "")) ax.text(c + (0.05 if c >= 0 else -0.05), i, sig, va='center', ha='left' if c >= 0 else 'right', fontsize=12) ax.errorbar(c, i, xerr=1.96 * se_i, fmt='none', color='black', capsize=4) ax.axvline(0, color='black', lw=1) ax.set_title("図4: 重回帰分析 標準化係数(宿泊者数損失を従属変数)", fontsize=13) ax.set_xlabel("標準化回帰係数(95% CI)") ax.grid(True, axis='x', alpha=0.3) plt.tight_layout() plt.savefig(FIGDIR + "2024_U1_fig4_coef.png", dpi=150) plt.close() print("fig4 saved") print("All figures saved.") |
fig4 saved All figures saved.
fig, ax = plt.subplots(...) — 図全体(fig)と軸(ax)を作る定番。以降は ax.bar(...) 等で操作。ax.axhline / ax.axvline — 水平/垂直の点線。平均線や基準線として定番。StandardScaler().fit_transform(X) — 各列を「平均0・分散1」に標準化。単位が違う変数のβを比較可能に。fig.savefig(..., bbox_inches='tight') — 余白を自動で詰めて保存。plt.close() でメモリ解放。x if cond else y は三項演算子。リスト内包表記と組み合わせると、forとifを1行で書けます。| データ | 出典 |
|---|---|
| 宿泊旅行統計調査(都道府県別月次) | 観光庁 宿泊旅行統計調査 |
| 外国人旅行者統計 | 日本政府観光局(JNTO) |
| 産業構造データ | SSDSE-B(社会・人口統計体系) |
本教育用コードは合成データを使用(np.random.seed(42))。実際の分析は観光庁の実データによる。
統計分析の解釈で初心者がやりがちな勘違いをまとめます。特に「相関と因果の混同」「p値の過信」は研究現場でもよく起きる落とし穴です。本文を読む前にも、読んだ後にも、目を通してみてください。
統計の基本用語を初心者向けに解説します。本文中で見慣れない言葉が出てきたら、ここに戻って確認してください。
統計手法について「何のためか」「結果をどう読むか」を初心者向けに解説します。
この研究をさらに発展させるための3つの方向性を示します。「今回わかったこと(X)」から「次に検証すべき仮説(Y)」を立て、「具体的に何をするか(Z)」まで考えてみましょう。
学んだだけでは身につきません。実際に手を動かすのが最強の学習方法です。本論文のスクリプトをベースに、以下のチャレンジに挑戦してみてください。難易度別に5つ用意しました。
本論文で学んだ手法は、研究の世界だけでなく、行政・企業・NPO の現場でも様々に活用されています。具体的なシーンを紹介します。
この論文を読んで初心者が抱きやすい疑問に、教育的観点から答えます。