はじめに
こんにちは、事業会社で働いているデータサイエンティストです。
今回は個人研究で進めている、台湾の国会にあたる立法院の議事録をデータにする話です。
日本には、国立国会図書館の国会会議録検索システムAPIがあります。発言本文だけでなく、発言者、会議、日付、発言順も取得できます。
これ、研究にとってめちゃくちゃありがたいです。
たとえば、井元(2024)は国会会議録と法案データを組み合わせ、野党の態度と法案の成否・成立時間の関係を分析しています。永渕ほか(2024)は国会・地方議会の会議録からコーパスと政治分野向けの言語モデルを構築しています。
政治学にも、自然言語処理にも使える。これがデータ基盤のありがたさです。
私も台湾の立法院で、いろいろ分析したい。
そこで立法院公報のPDFを読み始めたのですが、気づいたら、分析モデルより先にパーサーとデータ基盤を作っていました。
モデルを回す前に、PDFを回しています。
2022年、まずは自分で書いた
最初のパーサーを書いたのは2022年です。生成AIの助けは借りず、友人にも一緒に考えてもらいながら、Rで実装しました。
当時一緒に悩んでくれた友人には、この場を借りて感謝します。ありがとうございました!
当時のコードを読み返すと、「発言者っぽく見えるけれど発言者ではないもの」を一つずつ除く処理が、かなりの部分を占めています。PDFから文字を取り出し、発言を分け、まず分析に進むための試作でした。新しい形式に出会うたびに、確認と修正の仕事が増えていきます。
そこから今回、ChatGPTとパーサーを作り直しました。ただし、最初の書き直しで完成したわけではありません。
現在のパーサーとPDFをChatGPTに渡し、怪しい出力を原文と突き合わせて、改善してもらう。
そして、修正結果を確認し、別のPDFでも確かめる。この繰り返しです。
2022年との違いは、「コードを一度書けるか」以上に、新しい失敗例を調べて、修正を積み重ねる作業を続けられるかでした。生成AIのおかげで、この反復に取り組める規模が大きく変わりました。
「話者名:発言」で分ければ終わり、ではなかった
公報を眺めると、話者名の後ろに「:」があって、発言が続いています。
中国語ですが、立法院公報第 109 卷第 74 期スクショを載せておきます:
なお、台湾の著作権法第9条では「公文」等は著作権の対象にならないとされています。立法院公報についてもこれに該当すると理解しています。中国語ですが、該当する条文を載せておきます:
第 9 條
下列各款不得為著作權之標的︰
一、憲法、法律、命令或公文。
二、中央或地方機關就前款著作作成之翻譯物或編輯物。
三、標語及通用之符號、名詞、公式、數表、表格、簿冊或時曆。
四、單純為傳達事實之新聞報導所作成之語文著作。
五、依法令舉行之各類考試試題及其備用試題。
前項第一款所稱公文,包括公務員於職務上草擬之文告、講稿、新聞稿及其他文書。
リンクはこちらです;
なんだ、区切ればいいのか。
……と思いたくなりますよね。
| PDFで起きること | 何が困るのか |
|---|---|
| 「提案人:」「表決結果:」も出てくる | コロンの前を何でも話者にすると、文書の項目が発言者になる。「表決結果」は採決結果のことです。 |
| 一つのPDFに複数の会議や、前回会議の議事録が入る | 過去の出席者や発言を、今の会議のものとして取り込んでしまう。掲載順が日付順とも限りません。 |
| 会話の途中に法案や書面資料が入る | 次の話者までを全部つなぐと、何十ページもの法案が直前の人の発言になる。 |
| 姓と名の間に役職が入り、時刻まで付く | たとえば「林委員德福(17時16分)」は、林德福という人の発言表示。切り分けを誤ると、人名だけでなく発言の開始自体を見落とす。 |
さらに、本文中には他人の発言の引用もあります。改行や文字化けもあります。同じ「主席」(議事進行役)という表記でも、途中で担当者が替わることがあります。
ここで怖いのは、間違っていても、きれいな表ができてしまうことです。
列はそろっている。文字列も入っている。エラーも出ない。
でも、その長文、本当にその人が話しましたか?
だから現在は、本文に加えて、会議、発言順、話者と役職、元の表記、PDFのページ・行などを残しています。別会議の情報が混ざらないように会議の境界を扱い、怪しい出力から原資料に戻れるようにする。
「正規表現を増やす」だけでは済まないのです。ある例外を救う修正が、別の正しい発言を壊すこともあるので。
なお、パーサー本体は現時点では公開していません。今回は、その開発で何に苦労したかの記録です。
生成AIに、そのまま抽出してもらえばよくない?
これは自然な疑問です。私も考えました。
ただ、今回は継続して使えるデータ基盤を作りたい。立法院公報は、機械処理には扱いにくいところがあっても、高度に定型化された、予測可能な文書でもあります。
そこで私は、抽出をルールベースで実行し、そのルールの開発・改善に生成AIを使っています。
理由は三つあります。
失敗から得た知識を、実行できる形で残せる
「このPDFで何を誤認したか」「なぜこの処理を追加したか」「なぜ以前の処理を外したか」をコメントに残し、判断そのものをコードにします。
次に読む人間や、別のAIにも、会話の経緯を全部覚えていてもらう必要が減ります。
もちろん、AIに自然言語の引き継ぎメモを書いてもらうこともできます。でも、プログラミング言語は実行条件をより厳密に指定できます。解釈の余地という意味で、自然言語より「エントロピーが低い」形に落とし込めるわけです。
同じ条件で、同じ出力を確認できる
入力、コード、実行環境をそろえれば、同じ出力を得られます。修正前後を比較でき、私以外の研究者も検証しやすい。
もちろん、同じ結果が出ることと、正しいことは別です。バグも律儀に再現します。
だからこそ、どの修正で何が変わったかを追えることが大事です。
全件を毎回AIに読ませるコストが重い
私の環境でChatGPTの6 AstraにPDFを30本まとめて渡したところ、一度の依頼で、週の利用枠の残り表示が約27%になりました。
手元にあるPDFは1,484本。分析対象として考えているのは、全部で2,391本です。
まだ30本なんですが。
これは私の利用時の経験で、一般的な料金表や処理性能の比較ではありません。ただ、修正のたびに大量のPDFを再処理するなら、運用コストは避けて通れません。
誰かが計算費用を全部持ってくれるなら、全面AI方式も真剣に検討します笑
既存データもある。そのうえで、何を作るのか
台湾に議会データがないわけではありません。公式サービスも、市民技術コミュニティや報道機関による蓄積もあります。ただ、、、
既存データはある。それでも「なんか違うぞ」となる理由
このパーサーを作っている間、生成AIには何度も聞いていました。
これ、絶対に誰かが作っているでしょう。私が作ろうとしているようなデータセットはありませんか?
すると、公式サイト、API、GitHub、公開コーパスが出てきます。
おお、あるじゃん!
……と思って中を開くと、なんか違うぞ。
「發言數據(発言データ)」とあれば発言の本文を期待しますが、発言回数の集計だったり、発言者の名簿だったりすることもあります。
本文があっても、議員ごとに集約されている。対話が残っていても、一つの長い文字列に入っている。必要な形式に近づいたと思ったら、研究したい時期が対象外。
さらに、本文を分割するコードがあっても、その分割がどこまで正しくできているかは、また別の問題です。
これは自分のコードでも経験しました。友人に相談しながら書いた2022年版も、PDFから発言を取り出すパーサーでした。しかし、生成AIと実際のPDFを確認しながら改修した現在版では、会議の境界、引用、書面資料、役職表記、議長交代などへの対応が大きく広がっています。
両方とも「PDFをパースできます」。でも、その一言では差が見えません。
「○○委員が言いました」は、○○委員の発言とは限らない
具体例を二つ挙げます。
一つ目は、楊麗環の発言に含まれる、次のような部分です。1
突然跟委員講:「我不是,我是電子票券」
「委員に突然こう言った:」と、電子チケットに関する言葉を紹介しています。これは楊麗環が話している途中の引用で、新しい話者の登場ではありません。
もう一つは、潘維剛の発言に含まれる、次の部分です。2
蕭美琴委員也說了:……
日本語なら「蕭美琴委員もこう言っていました:……」です。
人名、役職、コロン。見た目は話者の見出しに似ています。でも、その場で蕭美琴へ話者が交代したわけではありません。
人間には分かります。しかし、PDFの改行や文書変換が加わると、機械には厄介です。引用の導入部分だけが、発言者の見出しに似た位置へ現れることがあります。
ここを間違えると、変な話者が一人増えるだけでは済みません。本物の議員の発言が途中で切れ、その続きが別の話者に帰属します。 後から「議員以外を除く」と、誤った話者名と一緒に、本物の議員の発言まで落としてしまう可能性があります。
処理は正常終了。表もできた。でも、「誰が何を言ったか」が変わっている。
これが、私が怖いと思っている失敗です。
既存データは、どこまで扱っているのか
まず、期間と提供単位を整理します。以下は2026年9月時点で確認できた範囲です。
| データ・プロジェクト | 確認できた期間・範囲 | 提供内容と、今回確認したい点 |
|---|---|---|
| 立法院「議事及發言」・開放API3 | 検索システムの案内は1970年9月以降。PDFの提供範囲は別途記載。 | 検索、公報目録、原資料リンク、発言名簿、動画情報など。検索できる年代と、全文を一括取得できる範囲は同一とは限らない。確認した名簿APIも逐語本文ではない。 |
| g0v / twlyparser・ly-gazette4 | 2012〜2013年の公開例を確認。全収録期間は今回未確定。 | 話者・本文などを構造化する先行実装。引用を含む入力について、公開コードのテキスト処理経路を確認した。 |
| OpenFun / lysayit-api5 | READMEの記載は2018〜2021年。現行データ全体の収録期間は未確認で、この記載を現在の上限とはみなせない。 | 会議ID、行番号、話者、議員/その他の区分、本文を保存。引用の分割と、話者情報を整理する処理を確認した。 |
| LYAPI・公報議程の公開データ6 | 第8〜11期。確認した一括配布ファイルの会議日は2012-02-24〜2025-02-11。現行APIにはそれ以降の資料もある。 | 議程メタデータに加え、本文の解析結果も提供。今回の2014年の二例について、実際の解析済み応答を確認できた。 |
| 報導者の2024年公開データ7 | 第10期第1〜7会期。 | 23,511行。同じ議員の会議内発言をまとめ、省庁などに応じて分割。元の応答順をそのまま分析する単位ではない。 |
| 現在の報導者觀測站8 | 2020年以降を対象に更新。 | 官僚・議長との応答も掲載。確認した公開コードでは質疑区間の本文を扱っており、その内部の一回一回の発言とは粒度が異なる。 |
| taiwan-legislator-transcript9 | 確認した主な公開区分は2022年〜2025年初頭。別ファイルに2021年の資料もある。 | 動画クリップに対応する逐字稿。複数人の応答を一つの本文に収める。今回の2014年の二例は、この確認範囲には含まれない。 |
収録開始年、期間内の網羅性、一括取得できる範囲は、それぞれ確認が必要です。
古い資料が一件あることは、その後の全期間を網羅している証拠にはなりません。件数も、一行が議程なのか、質疑区間なのか、議員別の集約なのか、一回の発言なのかで意味が変わります。
コードの確認と、実データの確認で分かったこと
ここでは、公開コードに入力を与えた限定的な確認と、実際に提供されているデータで確認したことを分けます。
g0v・lysayitでは、引用を話者として切る動作を再現できた
先ほどの原文抜粋を基に、改行を含む短い入力を公開コードの該当処理へ渡すと、g0vのテキスト入力経路とlysayitの抽出関数では、引用の導入部分を独立した話者として扱う動作を確認できました。45
lysayitでは、その断片は議員名簿に一致せず、「その他」の話者になります。つまり、名簿との照合があっても、間違った場所で切られた本文が、自動的に元の議員へ戻るわけではありません。
ただし、これは入力条件を限定した確認です。各サービスが実際に使ったWord・HTML変換を含む全工程や、配布コーパスで同じ誤りが発生していると確認したものではありません。特にg0vには、HTMLの段落を扱う別の経路もあります。
それでも、公開コードのその処理だけでは、こうした入力を正しく扱えないことは分かります。「パーサーが存在する」という情報からは見えなかった、確認すべき条件です。
現行LYAPIは二つの引用を誤分割していなかった。ただし、本当の話者交代が同じブロックに入っていた
ここは実際のAPI応答を確認できました。
現行LYAPIでは、「蕭美琴委員也說了:……」は潘維剛の発言ブロックに残り、電子チケットの引用も独立した話者にはなっていませんでした。10
ところが、電子チケットの引用がある箇所では、一つのブロックに次の二人の発言が入っていました。
石主任委員世豪:……
楊委員麗環:……
行政側の答弁と、その次の議員の発言です。続くブロックでも、同様の結合を確認しました。
公開コードを読むと、今回の文書が通る分岐には、本文に読点がある段落を、現在のブロックへ追加する処理があります。今回の応答では、本当に話者が替わった議員の発言も、前のブロックに含まれていました。11
会話は残っています。順番も残っています。しかし、一つのブロックを、そのまま一人の一回の発言として数えることはできません。
これは重要な違いです。
引用で切りすぎないことと、本当の話者交代を取り逃さないこと。その両方が必要になります。
話者名や役割の情報も、別途確認が必要
g0vには、議事進行者の表記を話者欄で「主席」にまとめる処理があります。lysayitには、話者表記の括弧内を削除する処理があり、例えば代理を示す情報が、その話者欄から失われる場合があります。125
LYAPIのpersonsも、正規化済みの人物一覧とは限りません。別の実応答では、人名や「主席」に加え、「環境部書面資料」のような文書のラベルも含まれていました。13
したがって、分割されたブロックがあること、話者らしい文字列があること、人物と役割が確定していることは、それぞれ違う段階です。
集約済みのデータには、集約前へ戻れない限界もある
taiwan-legislator-transcriptの出力コードでは、動画クリップごとに複数の本文ブロックを一つの文字列へまとめています。本人以外の答弁も入るため、行に付いた議員名を全台詞の話者として使うことはできません。9
現在の報導者觀測站にも対話は残っていますが、確認した公開の定義・取得経路は、本文内部の各発言について人物・役割・順序・PDFページを独立した項目として返す形式ではありません。8
一方、2024年の公開データは、議員の関心議題を分析するために発言を集約し、議長の進行発言などを除外しています。この表だけから、元の応答順や除外された発言を復元することはできません。7
それぞれの目的に沿った設計ですが、私の研究には、さらに細かい単位が必要でした。
私が力を入れているのは、例外対応の蓄積
公報には強い規則性があります。それでも、最初から完全な「万能文法」があるとは仮定していません。
少数のルールで十分に処理できるなら、年代や会議の種類が変わり、引用や書面資料が混ざっても通用するかを確認したい。対応の説明や検証結果が見えない部分は、品質を保留して、難しい原資料と重点的に照合します。
これは公式・非公式を問わず、自分のパーサーにも当てる基準です。提供元や「逐字稿」という名称だけでは、抽出品質まで確認済みにはなりません。
今回のパーサーには、先ほどの二例を含め、実際のPDFで確認した問題への対応を加えています。処理を追加・変更した理由と、その根拠となる資料もコード内に残しています。
対象は引用だけではありません。
- 発言の途中に現れる人名や役職表記
- 前回議事録の再掲や、別会議との境界
- 書面資料・法案・表などと口頭発言の区別
- 議長や代理の議事進行役の交代
- 改行や表記の違いによる、発言の分断や結合
こうした事例を集中的に確認し、対応を積み重ねていること自体が、今回の重要な貢献です。
もちろん、ルールの本数が多ければ高品質というわけではありません。大事なのは、どの失敗を扱い、その修正で本来の発言まで失っていないかを確かめられることです。
同じ正解データによる全方式の精度比較はまだ行っていません。ここで示しているのは、確認した実装・入力・実応答の範囲での違いと、自分のパーサーに積み重ねている対応です。
発言から原PDFへ戻れることも、機能の一部
例外対応と並んで重視しているのが、個々の発言に対応するPDFの開始・終了ページと、抽出元の行位置です。
lysayitの行番号は変換テキスト上の位置です。LYAPIのblock_linesも、コード上ではHTML段落の通し番号で、PDFページ番号ではありません。また、公式目録やLYAPIにある議程単位のページ情報と、発言ごとの位置も粒度が異なります。514
今回のデータでは、気になる発言からPDFの該当箇所へ戻れるようにしています。PDF内のページ位置と、紙面に印刷されたページ番号は区別しています。
これは、抽出ミスを探すためだけの機能ではありません。公報に掲載された表、図、提出資料、前後の記載を読むためにも使えます。
「この表を見てください」のこの表は、発言本文だけでは分からないかもしれないのです。
一つの分析で使い切らないデータへ
私が作っているのは、発言単位を保ち、人物・役割・会議・順序・出典・確認情報を一緒に扱えるデータです。
対象は2012年以降。2020年以降の報導者のデータや、近年の動画クリップを中心とするコーパスより前の時期も扱い、複数の任期をまたぐ比較につなげます。2012年の資料へのアクセスや話者分割自体は、既存の取り組みにもあります。今回の価値は、その期間を含む資料に対して、例外対応と検証可能な発言単位の整備を進めることにあります。
また、大規模な自動収集と頻繁な更新を支える収集パイプラインも整備しています。 継続して資料を追加し、改善をデータへ反映できることも、このプロジェクトの一部です。
解析は進行中です。現時点の年別集計には2012〜2022年と2026年の発言が含まれますが、全期間の収録完了を意味しません。まだ集計に現れない年を「発言ゼロ」とも扱いません。
私自身の予備分析では、最終的に議員×月などへ集約します。それでも元の発言単位を残すのは、別の研究で、答弁前後の感情表現、会話の話題転換、議員の発言を使ったチャットボットなどにも利用できるようにしたいからです。
集約は後からできます。集約で失った情報は、その表だけからは戻せません。
生成AIに聞くたびに「なんか違うぞ」と感じたのは、関連するデータの存在と、こうした条件がそろったデータの間に、まだ作業が残っていたからでした。
その作業を、研究者が毎回最初からやり直さずに済むようにする。例外対応、原典への追跡、継続更新まで含めて、今回のプロジェクトで形にしたいのはそこです。
執筆時点のデータ収集状況ですがが、2012年以降の日付が付いた発言レコードは4,320,327行になりました。
年別の行数です(まだ処理途中ですが)
| 年 | 行数 |
|---|---|
| 2012 | 506,607 |
| 2013 | 529,510 |
| 2014 | 399,485 |
| 2015 | 330,169 |
| 2016 | 510,529 |
| 2017 | 443,508 |
| 2018 | 385,185 |
| 2019 | 271,878 |
| 2020 | 425,419 |
| 2021 | 373,886 |
| 2022 | 141,736 |
| 2026 | 2,415 |
| 合計 | 4,320,327 |
2023〜2025年は、この時点の集計には現れていません。ほかの年についても全件収録を保証する数字ではありません。抽出・検証は継続中で、この表をそのまま年ごとの発言量の増減として解釈することはできません。
自分の最初の分析だけに合わせない
私は予備分析で、議員・月・委員会などの単位に発言をまとめるつもりです。
ただ、基礎データまで集約してしまうと、後から使いたい情報を戻せません。
たとえば、Rossiter(2022)“Measuring Agenda Setting in Interactive Political Communication”は、会話の中で話題がどこで変わり、誰が話題を動かす傾向を持つかをモデル化した研究です。
「誰が何について話したか」に加えて、誰の発言の後に、会話がどう変わったかが重要になります。議員別に全部つなげると、その順序が失われます。
台湾には、藍文君の「召集委員的性別權力模式―以第五屆立法院為例」という研究もあります。日本語でいえば、第5期立法院の委員会で、議長役を担う議員の性別と権力の使い方を調べる研究です。公報の内容から権力行使を分類・数量化しています。
こうした分類を、人手で判定したデータとの照合を前提に自動アノテーションできれば、後続の時期へ分析を広げられるかもしれません。既存研究が定義した概念を、大きなデータで検証し直せる可能性があります。
さらに、「どんな応答の後に怒りを示す表現が増えるのか」も調べたい。これは先ほどの論文と同じ測定対象ではありませんが、会話の順序を残す意義は共通しています。
もっと先には、過去の議会発言を用いた議員チャットボットの学習・評価用データにもなり得ます。その場合も、誰の、いつの発言かを追え、生成した応答を本人の発言と区別できることが前提です。
特定の研究目的に最適化したデータには、その目的に応じた価値があります。そのうえで、私が目指すのは、まだ自分が思いついていない研究にも使える基盤です。公報の口頭発言を中心に、集約する前の粒度を残しておきたい。
DuckDBが必要になった理由
ここまで来ると、次の問題が出てきます。
発言者は正しいか。日付は変ではないか。議員名簿と結合できるか。同じ文章はどれくらいあるか。
パーサーを直すたびに、この確認が必要です。
そこで、RでPDFを処理した結果をPDF単位のParquetファイルに保存し、DuckDBからまとめて読んでいます。議員情報も立法院の公式データから別に取得し、名簿として保存します。
発言と名簿のビューは、こうです。
CREATE VIEW speeches AS
SELECT *
FROM read_parquet('parquet_files/*.parquet');
CREATE VIEW legislator_master AS
SELECT *
FROM read_parquet(
'legislator_master/*_jie_legislator_master.parquet',
union_by_name = true,
filename = true
);
union_by_nameは複数ファイルの列を名前で対応させ、filenameはファイル名を参照できるようにする指定です。ビューは結果のコピーを固定するものではなく、参照時にクエリを実行します。公式ドキュメントにも、この使い方が載っています。
パーサーを修正した場合は、対象のParquetを作り直します。ビューを作っただけでPDFの再解析まで行われるわけではありません。
そして、DuckDBのローカルUIが想像以上に快適でした。テーブルを見て、SQLを書いて、結果を確認する。普段BigQueryのコンソールに慣れている私には、かなり親しみやすいです。
こんな感じです:
何より、次のようなクエリでも、手元ではすぐ結果が返ってきます。
議員名簿との結合、選挙区の表記統一、総統と同じ政党かの判定、重複する本文への条件、正規表現による加工、月単位の文章の連結まで、一度にやっています。
実際に使っている、予備分析用のSQL
with tb as (
select
*,
case
when areaName like '%平地原住民%' then '平地'
when areaName like '%山地原住民%' then '山地'
when areaName like '%不分區%' then '不分區'
when areaName like '臺北縣%' or areaName like '台北縣%' then '新北'
else replace(
regexp_replace(
regexp_extract(
trim(areaName),
'^(.+?[縣市])',
1
),
'[縣市]$',
''
),
'臺',
'台'
)
end as area,
case
when meeting_date < '2016-05-20'
and partyGroup = '中國國民黨' then 1
when meeting_date >= '2016-05-20'
and partyGroup = '民主進步黨' then 1
else 0
end as same_as_president
from speeches
left join legislator_master
on speeches.speaker_name = legislator_master.name
and speeches.legislature = cast(legislator_master.term as integer)
where partyGroup is not null
and speaker_role = '委員'
and meeting_date is not null
and partyGroup in ('中國國民黨', '民主進步黨')
and meeting_date >= '2012-01-01'
qualify count(*) over (partition by speech_content) = 1
)
select
speaker_name,
sex,
partyGroup,
area,
same_as_president,
date(date_trunc('month', meeting_date)) as month,
meeting_body_raw,
string_agg(
trim(
regexp_replace(
regexp_replace(
speech_content,
'\([^)]*\)',
'',
'g'
),
'[^\p{Han}\p{Latin}]+',
' ',
'g'
)
),
' '
) as speech
from tb
group by
speaker_name,
sex,
partyGroup,
area,
same_as_president,
date(date_trunc('month', meeting_date)),
meeting_body_raw;
こんな複雑なクエリも4秒くらいで終わります。速いです。処理のログはこちらです:
explain analysisの結果
┌─────────────────────────────────────┐
│┌───────────────────────────────────┐│
││ Query Profiling Information ││
│└───────────────────────────────────┘│
└─────────────────────────────────────┘
explain analyze with tb as ( select *, case when areaName like '%平地原住民%' then '平地' when areaName like '%山地原住民%' then '山地' when areaName like '%不分區%' then '不分區' when areaName like '臺北縣%' or areaName like '台北縣%' then '新北' else replace( regexp_replace( regexp_extract( trim(areaName), '^(.+?[縣市])', 1 ), '[縣市]$', '' ), '臺', '台' ) end as area, case when meeting_date < '2016-05-20' and partyGroup = '中國國民黨' then 1 when meeting_date >= '2016-05-20' and partyGroup = '民主進步黨' then 1 else 0 end as same_as_president from speeches left join legislator_master on speeches.speaker_name = legislator_master.name and speeches.legislature = cast(legislator_master.term as integer) where partyGroup is not null and speaker_role = '委員' and meeting_date is not null and partyGroup in ('中國國民黨', '民主進步黨') and meeting_date >= '2012-01-01' qualify count(*) over (partition by speech_content) = 1 ) select speaker_name, sex, partyGroup, area, same_as_president, date(date_trunc('month', meeting_date)) as month, meeting_body_raw, string_agg( trim( regexp_replace( regexp_replace( speech_content, '\([^)]*\)', '', 'g' ), '[^\p{Han}\p{Latin}]+', ' ', 'g' ) ), ' ' ) as speech from tb group by speaker_name, sex, partyGroup, area, same_as_president, date(date_trunc('month', meeting_date)), meeting_body_raw
┌────────────────────────────────────────────────┐
│┌──────────────────────────────────────────────┐│
││ Total Time: 2.29s ││
│└──────────────────────────────────────────────┘│
└────────────────────────────────────────────────┘
┌───────────────────────────┐
│ QUERY │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│ EXPLAIN_ANALYZE │
│ ──────────────────── │
│ │
│ 0 rows │
│ 0.00s │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│ PROJECTION │
│ ──────────────────── │
│ #0 │
│ #1 │
│ #2 │
│ #3 │
│__internal_decompress_integ│
│ ral_integer(#4, 0) │
│ #5 │
│ #6 │
│ #7 │
│ │
│ │
│ │
│ 29,201 rows │
│ 0.00s │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│ HASH_GROUP_BY │
│ ──────────────────── │
│ Groups: │
│ #0 │
│ #1 │
│ #2 │
│ #3 │
│ #4 │
│ #5 │
│ #6 │
│ │
│ Aggregates: │
│ string_agg(#7) │
│ │
│ │
│ │
│ 29,201 rows │
│ 2.80s │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│ PROJECTION │
│ ──────────────────── │
│ speaker_name │
│ sex │
│ partyGroup │
│ area │
│ same_as_president │
│ CAST(date_trunc('month', │
│ meeting_date) AS DATE) │
│ meeting_body_raw │
│ "trim"(regexp_replace │
│ (regexp_replace │
│ (speech_content, '\([^)]*\│
│ )', '', 'g'), '[^\p{Han} │
│ \p{Latin}]+', ' ', 'g')) │
│ │
│ │
│ │
│ 1,625,090 rows │
│ 6.76s │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│ PROJECTION │
│ ──────────────────── │
│ #0 │
│ #1 │
│ #2 │
│ #3 │
│ #4 │
│ #5 │
│ #6 │
│__internal_compress_integra│
│ l_utinyint(#7, 0) │
│ │
│ │
│ │
│ 1,625,090 rows │
│ 0.00s │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│ PROJECTION │
│ ──────────────────── │
│ meeting_body_raw │
│ meeting_date │
│ speaker_name │
│ speech_content │
│ sex │
│ partyGroup │
│ area │
│ same_as_president │
│ │
│ │
│ │
│ 1,625,090 rows │
│ 1.13s │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│ PROJECTION │
│ ──────────────────── │
│ #0 │
│ #1 │
│ #2 │
│ #3 │
│ #4 │
│ #5 │
│ #6 │
│ │
│ │
│ │
│ 1,625,090 rows │
│ 0.00s │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│ FILTER │
│ ──────────────────── │
│ (#7 = 1) │
│ │
│ │
│ │
│ 1,625,090 rows │
│ 0.01s │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│ PROJECTION │
│ ──────────────────── │
│ #0 │
│ #1 │
│ #2 │
│ #3 │
│ #4 │
│ #5 │
│ #6 │
│ #7 │
│ │
│ │
│ │
│ 1,704,840 rows │
│ 0.00s │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│ WINDOW │
│ ──────────────────── │
│ Projections: │
│ count() OVER (PARTITION BY│
│ speech_content) │
│ │
│ │
│ │
│ 1,704,840 rows │
│ 2.89s │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│ HASH_JOIN │
│ ──────────────────── │
│ Join Type: INNER │
│ │
│ Conditions: │
│ speaker_name = name │
│ legislature = CAST(term AS├──────────────┐
│ INTEGER) │ │
│ │ │
│ │ │
│ │ │
│ 1,704,840 rows │ │
│ 0.11s │ │
└─────────────┬─────────────┘ │
┌─────────────┴─────────────┐┌─────────────┴─────────────┐
│ TABLE_SCAN ││ FILTER │
│ ──────────────────── ││ ──────────────────── │
│ Function: ││ ((partyGroup = '中國國民黨│
│ READ_PARQUET ││ ') OR (partyGroup = '民主 │
│ ││ 進步黨')) │
│ Projections: ││ │
│ meeting_body_raw ││ │
│ legislature ││ │
│ meeting_date ││ │
│ speaker_name ││ 0.00s │
│ speech_content ││ │
│ ││ │
│ Filters: ││ │
│ meeting_date>='2012-01-01'││ │
│ ::DATE AND (meeting_date ││ │
│ IS NOT NULL) ││ │
│ speaker_role='委員' ││ │
│ ││ │
│ Dynamic Filters: ││ │
│ optional: legislature>=8 ││ │
│ AND optional: legislature││ │
│ <=10 ││ │
│ optional: speaker_name>=' ││ │
│ 丁守中' AND optional: ││ │
│ speaker_name<='黃秀芳' ││ │
│ ││ │
│ Total Files Read: ││ │
│ 1562 ││ │
│ ││ │
│ Filename(s): ││ │
│ parquet_files/*.parquet, .││ │
│ .. ││ │
│ ││ │
│ ││ │
│ ││ │
│ 1,939,908 rows ││ 335 rows │
│ 4.24s ││ (0.00s) │
└───────────────────────────┘└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│ TABLE_SCAN │
│ ──────────────────── │
│ Function: │
│ READ_PARQUET │
│ │
│ Projections: │
│ term │
│ name │
│ sex │
│ partyGroup │
│ areaName │
│ │
│ Filters: │
│ optional: partyGroup IN ( │
│ '中國國民黨', '民主進步黨'│
│ ) AND (partyGroup IS NOT │
│ NULL) │
│ │
│ Total Files Read: 3 │
│ │
│ Filename(s): │
│ legislator_master/ │
│ *_jie_legislator_master │
│ .parquet │
│ │
│ │
│ │
│ 372 rows │
│ 0.00s │
└───────────────────────────┘
ここでのQUALIFYは、抽出条件適用後に同じ本文が複数あれば、そのすべてを除く指定です。「重複を一件だけ残す」処理ではありません。また、半角括弧内を落とすなどの加工も、今回の予備分析のための選択です。基礎コーパスをこの定義で上書きしてはいません。
現在の名簿取得対象は第8〜10期で、このSQLは名簿と結合できた国民党・民進党の議員に絞っています。全発言を使うクエリでも、日付別の党籍履歴を完全に復元するクエリでもありません。
また、string_agg内で順序を指定していないため、連結順序は保証していません。会話の推移を分析するときは、この集約表ではなく、会議と発言順を保持したデータを使います。集約後の文字列にも再現可能な順序が必要なら、集約関数内でORDER BYを指定します。
これがすぐ返ってくると、疑問を思いついたその場で調べられます。
条件を変える。怪しい行を絞る。PDFに戻る。パーサーを直す。もう一度確認する。QAと改善のサイクルが速くなるわけです。
ここでの「速い」は私の環境での使用感です。Rやpandasとの統制されたベンチマークをした、という話ではありません。
でも、私のプロジェクトにDuckDBを導入する理由としては、十分に具体的です。
「データベースってかっこいい!データエンジニアリングやってみたい!業界標準っぽくしよう!」で導入したわけではありません。
本気の学術活動もビジネスと同じで、導入する仕組みには存在理由とROIの説明が必要だと思っています。
ここでの投資は、導入・学習・保守の時間。リターンは、繰り返す集計と確認の待ち時間を減らし、同じ時間でより多くの問題を検証できることです。研究の価値を売上に換算する話ではなく、限られた研究時間の使い方の話です。
RとSQLの間で、仕事を迷子にしない
私の基本方針は、分析対象の抽出、結合、共通の本文加工、集約はSQLでできるところまで進め、SQLでは扱いにくい処理や統計モデルの推定をRに渡すことです。
ただし、Rで分析している途中に「月を別の列にしたい」「曜日を見たい」「文字数や、分かち書き後の単語数が欲しい」と思うこともあります。そのたびにSQLへ戻って書き直しても、追加の価値が小さければRで済ませます。繰り返し使う共通定義になったら、SQL側へ戻すこともあります。
大事なのは、すべてを一つの言語に押し込むことより、何の定義をどこが持つかです。
SQLに慣れないうちは、まずselect * from tableで全部取り出し、後の処理をRで書きたくなるかもしれません。一回限りの小さな分析なら、それで十分な場合もあります。
ただ、処理が多数のRスクリプトに散らばると、
- この
speech列は、どの発言をつないだものなのか。 - 何を除外したのか。
- つなぐ前に、本文をどう加工したのか。
を知るために、コードを何本もたどることになります。未来の自分も、普通に困ります。
データの定義を知りたいだけなのに、コード探偵になる仕事に、研究上の追加価値はありません。
今回のSQLなら、対象者、対象期間、結合条件、除外条件、本文加工、集約単位が一続きで確認できます。もちろんSQLも散らかせば同じなので、使うクエリを保存し、定義の置き場所を決めておくことが前提です。
責任分担を考えるのは、将来の自分が定義を探し、修正の影響範囲を調べる苦労を減らすためです。
次は、ようやく分析へ
生成AIに手伝ってもらうことで、PDFから研究用データを作る作業を、継続して改善できる形に近づけられました。そして、その改善を回すためにDuckDBが必要になりました。
パーサーもデータの検証も、まだ進行中です。
次の記事では、このコーパスを使った分析を紹介する予定です!
最後に、私たちと一緒にバットを持ちたい方はぜひ下記のリンクもご確認ください:
-
立法院の公式iVOD発言記録・73619。引用は楊麗環の発言中にあり、その後に石世豪の答弁が続く。 ↩
-
立法院の公式iVOD発言記録・74784。蕭美琴への言及は潘維剛の発言中に含まれる。 ↩
-
立法院國會圖書館の収録範囲の案内。検索の収録開始とPDFの提供開始を別に記載。APIは公報原始檔案、院會發言名單、委員會登記發言名單などを確認。 ↩
-
twlyparserの話者前処理、テキスト入力経路、HTML段落の処理、ly-gazette。本文の確認は、質疑中の状態を設定して原文抜粋をテキスト経路へ渡した限定的なもので、DOC変換を含む全工程の検証ではない。 ↩ ↩2
-
lysayitの話者・本文の分割処理、話者表記の整理と保存項目、議員名簿との照合。限定テストでは原文抜粋に話者の文脈を補い、元の抽出関数と保存前処理を実行。DOC変換や公開データ全体の検証ではない。 ↩ ↩2 ↩3 ↩4
-
公報議程の公開データ。表の期間は確認した一括配布ファイルの範囲であり、現行APIの収録上限ではない。 ↩
-
報導者の公開データと集約・除外方針。23,511行は公開ZIP内のデータの件数。 ↩ ↩2
-
現行サービスの説明、公開コードのSpeech定義、本文等の取得処理。確認した定義・取得経路について述べている。 ↩ ↩2
-
aigrant/taiwan-legislator-transcriptと本文ブロックを文字列へ結合する処理。確認した期間内の全会議・全発言の収録を意味するものではない。 ↩ ↩2
-
実際に確認した解析結果は、電子チケットの引用を含む文書と蕭美琴への言及を含む文書。前者では0始まりのブロック145・146に、それぞれ石世豪と楊麗環の発言が入っていた。 ↩
-
LYAPIの書式情報に応じた話者判定と、読点を含む段落の処理。今回の文書の変換済みHTMLと実応答を照合した。 ↩
-
g0vの議事進行者の表記を「主席」にまとめる処理。 ↩
-
ラベルの集計・返却処理と、書面資料のラベルを含む2025年の実応答。 ↩
-
LYAPIのHTML段落の列挙・位置の計数と議程単位のページ情報。 ↩

