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

国会会議録 API で「賃上げ」の40年を数える:fugashi の名詞抽出・重み付き対数オッズ比・LDA の実装

1
Posted at

何を確かめたいか

国会の発言に「賃上げ」「物価」などの語がいつ増え、どんな言葉と一緒に語られてきたかを、1985〜2026年について調べました。この記事では、その分析を Python でどう実装したかに絞って書きます。結果の読み方は note の記事にまとめています。

コードはすべて GitHub の articles/F09_diet_text/ にあり、ここに載せるのはそこから抜き出したものです。

全体の流れ

pip install -r requirements.txt
cd src
python 01_fetch_api.py   # API から取得(約1,200回、30分ほど)。data/ にある取得済みファイルを使うなら飛ばす
sh run_all.sh            # 02 形態素解析 → 03 頻度 → 04 共起・トピック → 05 図
ファイル やること
01_fetch_api.py API から発言数と抜粋を取得(キャッシュつき)
02_tokenize.py 形態素解析(名詞だけ、人名・定型語を除く)
03_freq.py 出現率、統計との相関、会議の範囲・表記ゆれの頑健性
04_cooc_topics.py 年代別の特徴語、共起ネットワーク、LDA と頑健性、語の割合
05_figures.py 図5枚

使ったライブラリは pandas・numpy・matplotlib・scikit-learn・networkx・fugashi・unidic-lite・requests です。

1. データの取り方:国会会議録検索システム API

国立国会図書館の国会会議録検索システムには、発言単位で検索できる API(https://kokkai.ndl.go.jp/api/speech)があります。語(any)と期間(from・until)を指定すると、その語を含む発言の件数(numberOfRecords)と本文が JSON で返ってきます。

キャッシュつきの取得関数

同じ条件で何度もアクセスしないよう、パラメータの MD5 をファイル名にしてキャッシュします。サーバーに負担をかけないよう、取得のたびに1.2秒あけています。

BASE = "https://kokkai.ndl.go.jp/api/speech"
ROOT = Path(__file__).resolve().parents[1]
CACHE = ROOT / "data" / "raw" / "cache"
CACHE.mkdir(parents=True, exist_ok=True)


def get(params):
    key = hashlib.md5(json.dumps(params, sort_keys=True, ensure_ascii=False).encode()).hexdigest()
    f = CACHE / f"{key}.json"
    if f.exists():
        return json.loads(f.read_text())
    for _ in range(3):
        try:
            r = requests.get(BASE, params={**params, "recordPacking": "json"}, timeout=60)
            r.raise_for_status()
            j = r.json()
            f.write_text(json.dumps(j, ensure_ascii=False))
            time.sleep(1.2)
            return j
        except Exception:
            time.sleep(5)
    raise RuntimeError(params)

json.dumps(..., sort_keys=True) にしておくと、辞書のキーの順番が違っても同じキャッシュに当たります。途中で止まっても、再実行すれば取得済みの分は飛ばせます。

件数だけを取る:maximumRecords=1

出現率の分子と分母は「件数」だけあれば足ります。maximumRecords=1 にして本文をほとんど受け取らず、numberOfRecords だけを読みます。語を指定しない(any なし)ときの件数が、その年の発言の総数(分母)になります。

KEYWORDS = ["", "賃上げ", "物価", "少子化", "デフレ", "インフレ", "円安", "物価高", "好循環",
            "非正規", "生産性", "最低賃金", "働き方改革", "賃金引上げ", "賃金の引上げ", "ベースアップ"]
MAIN4 = ["", "賃上げ", "物価", "少子化", "デフレ"]
SCOPES = ["本会議", "予算委員会"]          # nameOfMeeting(部分一致)
YEARS = range(1985, 2027)
LAST_DAY_2026 = "2026-10-24"


def span(y):
    return f"{y}-01-01", (LAST_DAY_2026 if y == 2026 else f"{y}-12-31")


def counts():
    rows = []
    for y in YEARS:
        fr, un = span(y)
        jobs = [(k, "") for k in KEYWORDS] + [(k, s) for s in SCOPES for k in MAIN4]
        for k, s in jobs:
            p = {"from": fr, "until": un, "maximumRecords": 1}
            if k:
                p["any"] = k
            if s:
                p["nameOfMeeting"] = s
            rows.append([y, k, s, int(get(p)["numberOfRecords"])])
    ...

語×年×会議の範囲の組み合わせで、約1,100回のアクセスになりました。

本文の抜粋:無作為な位置から連続100件

話題の中身を見るために、「賃上げ」「物価」それぞれについて、年ごとに無作為な位置(startRecord)から連続100件の発言を取り、語の前後150字を最大5か所まで抜き出します。

def sample(rows, k_list=("賃上げ", "物価"), per=100, window=150, seed=12345):
    """年×語ごとに、ランダムな位置から連続100件の発言を取り、語の前後150字を抜き出す"""
    rnd = random.Random(seed)
    n_of = {(y, k): n for y, k, s, n in rows if s == ""}
    out = gzip.open(ROOT / "data" / "raw" / "contexts_sample.jsonl.gz", "wt", encoding="utf-8")
    for y in YEARS:
        for k in k_list:
            n = n_of.get((y, k), 0)
            if not n:
                continue
            st = 1 + rnd.randrange(n - per + 1) if n > per else 1
            fr, un = span(y)
            j = get({"from": fr, "until": un, "any": k, "maximumRecords": per, "startRecord": st})
            for r in j.get("speechRecord", []):
                sp = r["speech"]
                ctx, i, c = [], sp.find(k), 0
                while i >= 0 and c < 5:
                    ctx.append(sp[max(0, i - window): i + window]); i = sp.find(k, i + window); c += 1
                ...

発言の全文を使わず前後150字に絞ったのは、長い答弁の中でその語と関係のない部分まで拾わないためです。抜粋は合計6,632発言になりました。

2. 前処理:fugashi で名詞だけを残す

形態素解析(文を単語に分け、品詞をつける処理)には fugashi(MeCab の Python ラッパー)と辞書の unidic-lite を使いました。

正規化と発言者名の除去

会議録の本文は「○委員長(…) 」のように発言者名で始まります。これを正規表現で消し、数字も消しておきます。

def clean(s):
    s = unicodedata.normalize("NFKC", s)
    s = re.sub(r"○\S+? ", " ", s)          # 発言者名
    s = re.sub(r"[0-90-9,,..%%]+", " ", s)
    return s

NFKC 正規化で全角英数字や半角カナがそろうので、先に正規化してから正規表現をかけています。

品詞での絞り込み

    tagger = fugashi.Tagger()
    ...
        for w in tagger(clean(r["ctx"])):
            f = w.feature
            if f.pos1 != "名詞" or f.pos2 in ("数詞", "代名詞") or f.pos2 == "助動詞語幹" or f.pos3 == "人名":
                continue
            lemma = f.lemma if f.lemma and f.pos2 != "固有名詞" else w.surface
            lemma = w.surface if lemma is None else lemma
            if len(w.surface) < 2 or w.surface in STOP or lemma in STOP:
                continue
            toks.append(w.surface)
  • pos1 == "名詞" だけを残し、数詞・代名詞を除く
  • pos3 == "人名" で人名を除く(特定の議員の評価にならないようにするため)
  • 1文字の語と、ストップワード(「こと」「問題」「大臣」「答弁」など国会でどの話題にも出る語)を除く

ストップワード(STOP)には、定型語のほかに、日銀総裁や首相などの名前も入っています。pos3 == "人名" だけでは落としきれない名前を補うためだと思います(推測)。

3. 出現率と統計との相関

発言1万件あたりに直す

国会の発言数は年によって違う(1年に7万〜16万件)ので、件数を「発言1万件あたり」に直してから比べます。

c = pd.read_csv(DATA / "counts_by_year.csv", keep_default_na=False)
tot = c[c.keyword == ""].set_index(["scope", "year"]).n
c = c[c.keyword != ""].copy()
c["total"] = [tot[(s, y)] for s, y in zip(c.scope, c.year)]
c["per10k"] = c.n / c.total * 1e4

keep_default_na=False が大事です。語なし(総数)と会議の範囲なし(全会議)を空文字で保存しているので、これを付けないと pandas が空文字を NaN にしてしまい、keyword == "" で総数の行を取り出せなくなります。

3年ブロックのブートストラップで相関の幅を見る

年ごとの値は前の年と似やすい(自己相関がある)ので、1年ずつばらばらに取り直すと幅が狭く出すぎます。3年ずつのかたまりで取り直して、相関の95%区間を出しました。

def boot_ci(a, b, n=2000, seed=1):
    rng = np.random.default_rng(seed); idx = np.arange(len(a)); v = []
    for _ in range(n):
        # 3年のブロックブートストラップ(自己相関を考える)
        starts = rng.integers(0, len(a) - 2, size=len(a) // 3 + 1)
        ii = np.concatenate([idx[s:s + 3] for s in starts])[:len(a)]
        v.append(np.corrcoef(a.values[ii], b.values[ii])[0, 1])
    return np.round(np.percentile(v, [2.5, 97.5]), 3).tolist()

結果は、「物価」の出現率と消費者物価の上昇率が相関0.68(95%区間0.27〜0.84)、「賃上げ」の出現率と賃上げ率が全期間で0.23(−0.20〜0.66)でした。同じスクリプトで、期間を2013〜2025年に限った相関(賃上げは0.92)と、前後1〜2年ずらした相関も出しています。

語の出現率と統計

4. 年代ごとの特徴語:重み付き対数オッズ比

「賃上げ」と一緒に出る言葉を単純に数えると、どの年代でも「賃金」「企業」のような語が上位に来て、年代の違いが見えません。そこで、Monroe ほか(2008)の事前分布つきの重み付き対数オッズ比を使い、「その年代でほかの年代より目立つ語」を z 値で並べました。

def log_odds(cnt_a, cnt_b, prior, a0):
    """重み付き対数オッズ比(Monroe ほか 2008、事前分布つき)。z 値を返す"""
    na, nb = sum(cnt_a.values()), sum(cnt_b.values())
    out = {}
    for w in set(cnt_a) | set(cnt_b):
        ya, yb, aw = cnt_a.get(w, 0), cnt_b.get(w, 0), prior[w] * a0
        da = np.log((ya + aw) / (na + a0 - ya - aw)); db = np.log((yb + aw) / (nb + a0 - yb - aw))
        var = 1 / (ya + aw) + 1 / (yb + aw)
        out[w] = (da - db) / np.sqrt(var)
    return out

ポイントは2つです。

  • 数え方:set(t) にして「その語が出た発言の数」で数えています。1つの長い答弁で同じ語が何度も出ても、1件です
  • 事前分布:全年代の語の割合(prior)に a0=500 をかけた分を足しています。出現回数が少ない語ほど、全体の割合に引き寄せられるので、たまたま数回出ただけの語が上位に来にくくなります

呼び出し側では、さらに「その年代で8発言以上に出た語」だけを候補にし、z 値の上位12語を残しています。

検索語そのもの(「賃上げ」の抜粋なら「賃上げ」「賃金」)は KW で除いています。

5. 共起ネットワーク:Jaccard 係数

よく一緒に出る言葉どうしを線で結ぶ図です。年代ごとに、出た発言の数が多い30語をノードにし、2語の組み合わせを数えて Jaccard 係数(両方が出た発言の数 ÷ どちらかが出た発言の数)で強さを測ります。

    sets = [set(t) - KW["賃上げ"] for t in sub.toks]
    df_ = Counter(w for s in sets for w in s)
    nodes = [w for w, _ in df_.most_common(30)]
    co = Counter()
    for s in sets:
        for a, b in itertools.combinations(sorted(s & set(nodes)), 2):
            co[(a, b)] += 1
    edges = []
    for (a, b), n in co.items():
        jac = n / (df_[a] + df_[b] - n)
        edges.append((a, b, jac))
    edges = sorted(edges, key=lambda e: -e[2])[:45]

sorted(...) してから combinations を取ると、(A, B) と (B, A) が同じキーになります。単純な共起回数ではなく Jaccard 係数にしたのは、どちらもよく出る語どうしが、それだけで強く結ばれてしまうのを避けるためです。

描画は networkx の spring_layout で、乱数の種を固定しています(seed=3)。種を固定しないと、実行のたびに配置が変わります。

共起ネットワーク

6. トピックモデル(LDA)

LDA は、1つの文書が複数の話題の組み合わせでできていると考え、よく一緒に出る語のまとまりを「話題」として取り出す方法です。scikit-learn の LatentDirichletAllocation を使いました。

texts = [" ".join(t) for t in d.toks]


def fit(K, seed):
    vec = CountVectorizer(tokenizer=str.split, lowercase=False, min_df=10, max_df=0.4, token_pattern=None)
    X = vec.fit_transform(texts)
    lda = LatentDirichletAllocation(n_components=K, random_state=seed, learning_method="batch", max_iter=60,
                                    doc_topic_prior=0.1, topic_word_prior=0.05)
    th = lda.fit_transform(X)
    vocab = np.array(vec.get_feature_names_out())
    tops = [list(vocab[np.argsort(-lda.components_[j])[:15]]) for j in range(K)]
    return th, tops, lda.perplexity(X), X.shape

CountVectorizer の設定で気をつけた点です。

  • tokenizer=str.split:形態素解析はすでに済んでいるので、空白で区切るだけにしています。既定のままだと英語向けの区切り方(2文字以上の英数字)になり、日本語の語が正しく切れません
  • token_pattern=None:tokenizer を渡すときにこれを付けないと、使われない token_pattern について警告が出ます
  • lowercase=False:日本語には不要なので切っています
  • min_df=10, max_df=0.4:10発言未満にしか出ない語と、4割を超える発言に出る語を除いています

doc_topic_prior=0.1(文書ごとの話題の偏りやすさ)と topic_word_prior=0.05(話題ごとの語の偏りやすさ)は、どちらも scikit-learn の既定値(1/K)より小さくしています。

各発言の話題の割合(th)を年ごとに平均すれば、話題の構成比の推移になります。

話題の名前は、上位の語を見て私が付け、lda_labels.py に書いています。

トピックの構成

7. 頑健性チェックの実装

トピック数・乱数の種を変えたときの対応づけ

LDA は、トピック数や乱数の種を変えると、トピックの番号も中身も変わります。そこで、K=8 の各トピックについて、上位15語の Jaccard 係数がいちばん高いトピックを相手として選び、年ごとの構成比の相関を見ました。

for K, seed in [(6, SEED), (10, SEED), (8, SEED + 1), (8, SEED + 2)]:
    th2, tops2, ppl2, _ = fit(K, seed)
    sh2 = pd.DataFrame(th2).assign(y=d.y.values).groupby("y").mean()
    for j in range(8):
        jac = [len(set(tops[j]) & set(t)) / len(set(tops[j]) | set(t)) for t in tops2]
        m = int(np.argmax(jac))
        rob.append({"K": K, "seed": seed, "topic_k8": j, "best_match": m, "jaccard": round(jac[m], 2),
                    "share_corr": round(base_share[f"t{j}"].corr(sh2[m]), 3)})

構成比の相関の中央値は0.90〜0.97でした。

トピックモデルを使わない確認

LDA の結果だけに頼らないよう、「賃上げ」の抜粋のうち、決めた語のどれかを含む発言の割合も出しています。

groups = {"労使系(春闘・労使・組合・交渉)": {"春闘", "労使", "組合", "交渉"},
          "政策系(税制・支援・補助・助成)": {"税制", "支援", "補助", "助成"}}
rows = []
for lab in [p[2] for p in PERIODS]:
    sub = d[(d.k == "賃上げ") & (d.period == lab)]
    for g, ws in groups.items():
        rows.append({"period": lab, "group": g, "share": round(np.mean([bool(set(t) & ws) for t in sub.toks]), 3), "n": len(sub)})

労使系の語を含む割合は1985〜94年の50%から2015〜26年は17%に、政策系は5%から37%になり、LDA と同じ向きでした。

会議の範囲と表記ゆれ

03_freq.py では、全会議と本会議・予算委員会の出現率の相関(「賃上げ」0.98・0.99)と、「賃上げ」と「賃金の引上げ」(0.85)・「賃金引上げ」(0.75)・「ベースアップ」(0.35)の出現率の相関も、同じ pivot の形で出しています。

頑健性

8. 図の出し方

05_figures.py で5枚の図を作っています。出現率の推移の図では、2026年は10月24日までの途中の値なので、最後の1区間だけ点線にし、凡例の代わりに線の右端に語を書いています。日本語フォントは common.py で Noto Sans CJK を探して登録しています。

語の出現率の推移

つまずいた点と落とし穴

  • 会議名の指定は部分一致:nameOfMeeting="予算委員会" は分科会・公聴会も含みます。「予算委員会だけ」の数字として読むと少しずれます
  • 1発言1件:numberOfRecords は「その語を含む発言の数」で、出てきた回数ではありません。長い答弁が増えると、同じ件数でも語られた量は違います
  • 連続100件の抜粋は固まる:無作為なのは開始位置だけなので、同じ会議の発言がまとまって入ることがあります。1995〜2004年の「賃上げ」は発言そのものが少なく、173件しかありません
  • 抜粋は取り直すと少し変わる:記事では同じ手順をブラウザのページ内で実行して取得しており、抜粋を取る位置の乱数だけ 01_fetch_api.py と違います。01 で取り直すと、抜粋の中身は少し変わります
  • 人名の除去:品詞の pos3 == "人名" に加えて、ストップワードにも人名が入っています。辞書だけでは落としきれない名前があったためと思われます(推測)
  • LDA のトピックは種で混ざる:「春闘と労使交渉」は乱数の種によってほかの話題と混ざることがあり(構成比の相関0.50の組がありました)、そのために語の割合での確認を足しました

言えないこと

  • 発言の量は「国会でどれだけ話題になったか」の目安で、政策の効果や良し悪しを表すものではありません
  • 特定の政党・議員の評価はしていません(会派・発言者では分けておらず、人名は分析から除いています)
  • トピックの名前は、上位の語を見て私が付けたものです

参考

出典:国立国会図書館「国会会議録検索システム」検索用API、総務省「消費者物価指数」、厚生労働省「賃金引上げ等の実態に関する調査」をもとに作成者が加工して作成

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