総務省「家計調査」の月次データを使い、2014年4月(5→8%)と2019年10月(8→10%)の消費増税の前後で、支出がどれだけ前倒しされ(駆け込み)、その後どれだけ減ったか(反動)を測る実装をまとめます。結論(2019年は反動が小さく、軽減税率の対象の食料は落ち込まなかった)は note に書いたので、この記事は「どう実装したか」に絞ります。
確かめたかったことは、次の2つです。
- 増税の前後で、実質の支出は「増税がなかった場合」と比べてどれだけ動いたか
- その動きは、増税と関係のない月に同じ計算をしたときのぶれ(偽の増税月)より大きいか
使ったのは pandas・NumPy・matplotlib だけです。回帰も np.linalg.lstsq で書いています。
1. データの取り方
使ったのは、家計調査(家計収支編)二人以上の世帯の長期時系列表です。支出金額と、名目・実質の前年同月比の3つの表を使います。表の番号(statInfId)は共通の設定ファイルにまとめました。
# 増税の月(税率が上がった月を 0 とする)
EVENTS = {"2014": "2014-04", "2019": "2019-10"}
STAT_IDS = {
"amount": "000040270646", # 品目分類(小分類まで)支出金額(月)2000年1月〜
"nominal": "000040270647", # 名目増減率(月)
"real": "000040270648", # 実質増減率(月)
"items2015": "000040270649", # 全品目(2015年改定)支出金額(月)2015〜2019
"items2020": "000040270650", # 全品目(2020年改定)支出金額(月)2020〜2024
}
取得は e-Stat のファイルのダウンロード URL を叩くだけです。CSV は Shift_JIS なので、UTF-8 に直して保存します。
for key, sid in STAT_IDS.items():
url = f"https://www.e-stat.go.jp/stat-search/file-download?statInfId={sid}&fileKind=1"
r = requests.get(url, timeout=60)
r.raise_for_status()
(RAW / f"kakei_{sid}.csv").write_text(r.content.decode("shift_jis"), encoding="utf-8")
print(key, sid, len(r.content))
2. 横長の表を「項目×月」の縦長にする
長期時系列表は、2行目に年、4行目に月が横に並び、5行目以降が品目の行です。年のセルは年の最初の月にしか入っていないので、前の値を持ち越しながら YYYY-MM の列名を作ります。
def read_table(sid, name_col=5, first=6):
rows = list(csv.reader(open(RAW / f"kakei_{sid}.csv", encoding="utf-8")))
years = rows[1][first:]
months = rows[3][first:]
dates = []
y = None
for yy, mm in zip(years, months):
if yy.strip():
y = int(yy.strip().replace("年", ""))
if not mm.strip():
dates.append(None)
continue
dates.append(f"{y}-{int(mm.strip().replace('月', '')):02d}")
out = []
for r in rows[4:]:
if len(r) <= first:
continue
name = r[name_col].strip()
for d, v in zip(dates, r[first:]):
if d is None:
continue
v = v.strip()
try:
val = float(v)
except ValueError:
val = np.nan
out.append((int(r[0]), name, d, val))
return pd.DataFrame(out, columns=["no", "item", "date", "value"])
月の欄が空の列(年の合計など)は None にして読み飛ばします。数値にならないセルは NaN にしています。
3. 品目ごとのデフレーターを作る(実質化)
増税の前後では物価そのものが上がるので、金額のまま比べると「税率が上がった分」まで支出の増加に見えてしまいます。そこで実質化しました。
長期時系列表には、品目ごとの名目と実質の前年同月比があります。この2つから、前年同月に対する物価の比 (1+名目)/(1+実質) が分かります。2015年の各月を1として、12か月ずつ前後につないでいけば、品目ごとのデフレーター(物価の指数)ができます。
def chain(g):
g = g.set_index("date").sort_index()
ratio = (1 + g["nom"] / 100) / (1 + g["real"] / 100)
d = pd.Series(np.nan, index=g.index)
base = g.index[g.index.year == 2015]
d.loc[base] = 1.0
idx = list(g.index)
pos = {p: i for i, p in enumerate(idx)}
for p in idx: # 2016年以降は前に
if p.year > 2015 and (p - 12) in pos:
d.loc[p] = d.loc[p - 12] * ratio.loc[p]
for p in reversed(idx): # 2014年以前は後ろに
if p.year < 2015 and (p + 12) in pos:
d.loc[p] = d.loc[p + 12] / ratio.loc[p + 12]
g["deflator"] = d
g["real_amount"] = g["amount"] / g["deflator"]
return g.reset_index()
df = pd.concat([chain(g).assign(no=k) for k, g in df.groupby("no")], ignore_index=True)
ポイントは、2015年より前は「後ろから前へ」割り戻す点です。reversed(idx) で回さないと、まだ値の入っていない月を参照してしまいます。日付は pd.PeriodIndex(..., freq="M") にしておくと、p - 12 で12か月前が取れて楽です。
4. 品目をグループにまとめる
小分類をそのまま見ると月ごとのぶれが大きいので、合計してグループを作ります。軽減税率の対象は「酒類と外食を除く食料」で近似しました。
def grp(T):
g = pd.DataFrame(index=T.index)
g["消費支出"] = T["消費支出"]
g["耐久財"] = T["家庭用耐久財"] + T["教養娯楽用耐久財"] + T["自動車等購入"] + T["自転車購入"]
g["被服及び履物"] = T["被服及び履物"]
g["日用品"] = T["家事用消耗品"] + T["理美容用品"] + T["医薬品"] + T["保健医療用品・器具"]
g["食料(酒類・外食を除く)"] = T["食料"] - T["酒類"] - T["外食"]
g["外食"] = T["外食"]
g["酒類"] = T["酒類"]
return g
G = grp(W) # 実質
GN = grp(N) # 名目(頑健性チェック用)
W と N は、縦長の表を pivot_table(index="date", columns="item", ...) で横に広げたものです。
5. 「増税がなかった場合」を作る
この記事の中心です。系列ごとに、増税の48〜13か月前のデータで
log(実質支出) = 定数 + 傾き×月 + 月のダミー(季節)
を最小二乗で推定し、増税の12か月前〜12か月後に延ばします。2019年の場合は、2018年1月の家計簿の改正による段差のダミーも入れます。
def fit_cf(y, ev, est=(48, 13), step2018=True, horizon=12, norm=True):
"""y: 実質支出の月次系列。ev: 増税の月 (Period)。戻り値:k=-12..+12 の実際・予測"""
ev = pd.Period(ev, freq="M")
t0, t1 = ev - est[0], ev - est[1]
s = y.loc[t0: ev + horizon].dropna()
est_idx = s.index[(s.index >= t0) & (s.index <= t1)]
def X(idx):
t = np.array([(p - ev).n for p in idx], float)
cols = [np.ones(len(idx)), t]
for m in range(2, 13):
cols.append(np.array([p.month == m for p in idx], float))
if step2018:
cols.append(np.array([p >= pd.Period("2018-01", "M") for p in idx], float))
return np.column_stack(cols)
Xe = X(est_idx)
ye = np.log(s.loc[est_idx].values)
if step2018 and Xe[:, -1].std() == 0: # 推定期間に段差がなければ外す
return fit_cf(y, ev, est, False, horizon, norm)
beta, *_ = np.linalg.lstsq(Xe, ye, rcond=None)
resid_sd = np.std(ye - Xe @ beta, ddof=Xe.shape[1])
win = [ev + k for k in range(-12, horizon + 1) if ev + k in s.index]
pred = np.exp(X(win) @ beta)
out = pd.DataFrame({"k": [(p - ev).n for p in win], "date": [str(p) for p in win],
"actual": s.loc[win].values, "cf": pred})
raw = out["actual"] / out["cf"] - 1
ref = raw[(out.k >= -12) & (out.k <= -7)].mean() if norm else 0.0
out["gap_pct_raw"] = raw
out["cf"] = out["cf"] * (1 + ref) # 基準期間(12〜7か月前)のずれをそろえる
out["gap"] = out["actual"] - out["cf"]
out["gap_pct"] = out["actual"] / out["cf"] - 1
return out, resid_sd
実装上の工夫は3つあります。
- 推定期間を増税の13か月前で切る:駆け込みが始まる前のデータだけで「なかった場合」を作るためです
-
段差のダミーが全部0なら外す:2014年の推定期間には2018年が含まれないので、列が定数になり、定数項と区別できなくなります。
std() == 0で判定して、段差なしでやり直しています - 基準期間でそろえる:12〜7か月前の差の平均を0にして、そこからの動きを見ます。イベントスタディでよく使う「基準期間」の考え方です
駆け込みと反動は、この差を月ごとに足して出します。2019年は2020年3月以降にコロナの影響が入るので、反動は両年とも増税の月から4か月後までの5か月にそろえました。
def summarize(out, pre=(-6, -1), post=(0, 4)):
a = out[(out.k >= pre[0]) & (out.k <= pre[1])]["gap"].sum()
b = out[(out.k >= post[0]) & (out.k <= post[1])]["gap"].sum()
base = out[(out.k >= pre[0]) & (out.k <= pre[1])]["cf"].mean()
return dict(pull=a, payback=b, net=a + b, base=base,
pull_months=a / base, payback_months=b / base,
payback_ratio=(-b / a if a > 0 else np.nan))
pull_months は「駆け込みが平常の1か月分の何か月分か」です。耐久財と食料のように金額の桁が違うグループを並べるときに使います。
6. 偽の増税月(プラセボ)で幅を付ける
家計調査は標本調査なので、毎月それなりにぶれます。そこで、増税のない年(2005〜2012年・2016〜2017年)の4月と10月を「偽の増税月」にして同じ計算をし、そのばらつきの95%の範囲を作りました。2014年の増税期と推定期間・評価期間が重なる月は外しています。
plac_months = [f"{y}-{m:02d}" for y in list(range(2005, 2013)) + [2016, 2017] for m in (4, 10)]
plac_months = [p for p in plac_months if not (pd.Period(p, "M") + 12 >= pd.Period("2014-04", "M") - 6 and pd.Period(p, "M") - 13 <= pd.Period("2015-03", "M"))]
plac, band = [], []
for g in GROUPS:
for p in plac_months:
try:
out, _ = fit_cf(G[g], p, step2018=False)
except Exception:
continue
if out.k.max() < 4:
continue
r = summarize(out); r.update(group=g, month=p)
r["last_month"] = out.loc[out.k == -1, "gap_pct"].iloc[0]
plac.append(r)
band.append(out[["k", "gap_pct"]].assign(group=g, month=p))
plac = pd.DataFrame(plac)
band = pd.concat(band).groupby(["group", "k"])["gap_pct"].quantile([.025, .975]).unstack().reset_index()
groupby(["group", "k"]).quantile([.025, .975]).unstack() で、月数 k ごとの帯が1行で作れます。これが図1の灰色の帯になります。
7. 軽減税率の比較(差の差の差)
2019年は食料(酒類・外食を除く)が8%のままでした。食料と、税率が上がった日用品の差を、両方とも税率が上がった2014年と比べます。
did = []
for name, ev in EVENTS.items():
a, _ = fit_cf(G["食料(酒類・外食を除く)"], ev)
b, _ = fit_cf(G["日用品"], ev)
m = a[["k", "gap_pct"]].merge(b[["k", "gap_pct"]], on="k", suffixes=("_food", "_daily"))
m["diff"] = m["gap_pct_food"] - m["gap_pct_daily"]
m["event"] = name
did.append(m)
did = pd.concat(did)
この差の、増税前3か月と増税後5か月の平均を、2014年と2019年で引き算します。この「差の差」にも、偽の増税月で同じ計算をした範囲を付けました。
8. 結果の出し方(図)
図1は、グループごとに「なかった場合」との差(%)を、偽の増税月の帯と一緒に並べたものです。2019年は、コロナの影響が入る4か月後以降を破線にしています。
for ax, g in zip(axes.flat, groups):
b = band[band.group == g]
ax.fill_between(b.k, b.lo * 100, b.hi * 100, color=GRAY, alpha=0.45, lw=0, label="増税のない年(95%の範囲)")
for ev, c in [(2014, C14), (2019, C19)]:
p = paths[(paths.event == ev) & (paths.group == g)]
if ev == 2019:
ax.plot(p[p.k <= 4].k, p[p.k <= 4].gap_pct * 100, color=c, lw=2, marker="o", ms=3, label="2019年10月(8→10%)")
ax.plot(p[p.k >= 4].k, p[p.k >= 4].gap_pct * 100, color=c, lw=1.2, ls="--", alpha=0.6, label="2019年(2020年3月以降はコロナ)")
else:
ax.plot(p.k, p.gap_pct * 100, color=c, lw=2, marker="o", ms=3, label="2014年4月(5→8%)")
増税の前月(k=−1)の消費支出は、2014年が+11.4%、2019年が+8.5%で、偽の増税月の範囲(−5.2〜+4.2%)を超えていました。
図2は、6か月前からの差を cumsum() で累計したものです。
p["cum"] = p.gap.cumsum()
ax.plot(p.k, p.cum / 1000, color=c, lw=2.2, marker="o", ms=3.5, label=f"{ev}年")
増税後5か月の反動は、2014年が1世帯あたり−8.59万円(駆け込みの2.8倍)、2019年が−3.35万円(駆け込みとほぼ同じ)でした。
9. 頑健性チェックを関数1つで回す
設定を変えた再計算は、add() にキーワード引数を渡すだけで回るようにしました。
rob = []
def add(label, **kw):
for name, ev in EVENTS.items():
for g in ["消費支出", "耐久財", "日用品", "食料(酒類・外食を除く)"]:
out, _ = fit_cf(G[g] if not kw.get("nominal") else GN[g], ev, est=kw.get("est", (48, 13)), step2018=kw.get("step", True), norm=kw.get("norm", True))
r = summarize(out, pre=kw.get("pre", (-6, -1)), post=kw.get("post", (0, 4)))
r.update(spec=label, event=name, group=g)
rob.append(r)
add("基本(推定48〜13か月前・駆け込み6か月・反動5か月)")
add("推定期間を短く(36〜13か月前)", est=(36, 13))
add("推定期間を長く(72〜13か月前、2019年は2014年の増税期を含む)", est=(72, 13))
add("駆け込みを3か月に", pre=(-3, -1))
add("反動を3か月に", post=(0, 2))
add("2018年の段差を入れない", step=False)
add("名目のまま(実質化しない)", nominal=True)
add("基準期間でそろえない", norm=False)
耐久財の駆け込みは、どの設定でも2014年(平常の1.5〜2.9か月分)が2019年(0.7〜1.8か月分)より大きくなりました。
10. つまずいた点と落とし穴
- 金額のままだと増税分が混ざる:増税後は値段が上がるので、名目の金額には税率が上がった分が入ります。名目と実質の前年同月比からデフレーターを作って実質化しました。頑健性チェックでは「名目のまま」も残しています。2014年の食料の反動は、この設定でだけ落ち込みが見えなくなりました(実質では −0.12〜−0.24か月分)
- 2018年1月の家計簿の改正:一部の品目の水準がここで変わっています。段差のダミーで調整しましたが、完全ではありません
-
2014年の推定期間に段差の列を入れると解けない:列がすべて0になるので、
std() == 0で外しています - コロナとの重なり:2019年は2020年3月以降を評価に使えません。比べやすいように、2014年も反動を5か月にそろえました
- 偽の増税月が2014年の増税期と重なる:推定期間(13か月前まで)や評価期間(12か月後まで)が2014年4月の前後にかかる月は、プラセボから外しました
- 6か月の駆け込みの合計は幅が大きい:消費支出の6か月の合計は、偽の増税月の範囲(−4.8万〜+5.5万円)に入ってしまいます。はっきり言えるのは「前月の跳ね上がり」と、耐久財・日用品の動きです
言えないこと
- 集計値の動きなので、同じ世帯が前倒しして後で減らしたのかは分かりません
- 2014年の増税後の長い落ち込みには、反動だけでなく、実質の所得が減った分も入っていて、分けられません
- ポイント還元・台風・パソコンの買い替え時期など、同じ時期のほかの出来事が混ざっています
- 「酒類・外食を除く食料」は軽減税率の対象の近似です
参考
- note 記事(結論と解釈):https://note.com/takayukimatsuura/n/n1f8874471518
- コード・結果・図(GitHub):https://github.com/takayuki-0127/japan-open-data-notes/tree/main/articles/F11_tax_hike_event
- 出典:総務省「家計調査」(e-Stat、https://www.e-stat.go.jp/stat-search/files?toukei=00200561)を加工して作成



