投票行動を正規分布で把握する 【JavaScript】

Python

統計的な把握の試みとして、選挙の投票行動を正規分布でモデリングしてみます。
JavaScript でシミュレータを自作し、できるだけシンプルなモデルで現象の把握が可能か調べることにします。

以下の環境で動作確認をしています。
環境: Windows パソコン、Microsoft Edge

背景 ~ 投票行動は簡単な正規分布のモデルで把握できるのではないだろうか

最近、衆議院選挙など選挙が続いています。今後も選挙が続きます。

ニュースの解説などを見ていると感覚的な用語がたくさん出てきます。たとえば、「無党派層」、「浮動票」、「風が吹く」、「票を固めきる」、などです。
解説者や立候補者には、政治経済分野の大学の学部を出ている方もいると思うのですが、定量的な把握や分析があまりないようです。また、専門分野を学んでいるはずなのに、言っている内容が人によりバラバラです。具体的な数字を示して考えている内容を説明してほしいものです。

私は理系分野を専攻しており、何かの現象を観察する場合、定量的な把握が重要だと思っています。
選挙結果を見ていると、人々の投票行動は正規分布などで統計的に扱うことができるのではないかと思うことがあります。
そこで最近の選挙結果から、どのような投票行動になっているのか定量的な把握を試みることにします。

基本的な考え方

・ 簡単のため、有権者の投票行動を1次元の軸上(x軸)で把握するものとします。1次元のモデルが使えそうであれば、2次元などに拡張すればよいです。
・ 各党の固定支持層は、各党ごとに x軸上の x=x0 を中心に正規分布状に存在するものとします。
・ 選挙ごとに投票先が変動する現象をモデル化するため、無党派層を定義します。
無党派層も同様に、x軸上で正規分布状に存在するものとします。
・ 各正規分布は、x軸上に中心位置 x = x0、標準偏差 sigma1、面積 area1 で定義します。
・ 各党の固定支持層と無党派層を重ねたプロファイルが有権者全体の分布となります。プロファイルの面積が有権者数、票数(∝議席数)に対応します。
・ 隣り合う2つの政党( x = x1, x2 )について、x1 と x2 の中間位置に直線を引き、この直線のどちら側にあるかで投票先が決まるものとします(上記のグラフ参照)。つまり、複数の正規分布のプロファイルが重なる領域では、最も近い党に投票するものとします。

・ 上記のモデルを設定し、過去の選挙結果をうまく説明するように、各正規分布のパラメータを求めます。これにより、各党の位置(距離)関係、支持者の分布、無党派層の割合など、顕在化されていないパラメータを推測していきます。

使い方

動くサンプル

シミュレータの動くサンプルを以下に貼っておきます。まずは、下記の mutoha (ピンク)のグラフをドラッグしてみてください。

使い方

・ 上記の画面上で、ピンクの mutoha(無党派層の正規分布のグラフ)をドラッグしてみてください。無党派層が動くと議席数が増減するようすをシミュレーションできると思います。
上から2つ目のグラフに、対応する各政党の獲得議席数のシミュレーション結果が表示されます。
・ 同様に、各党のグラフをドラッグすると、議席数の変化をシミュレーションできます。
各党の政策・ポジショニングを、他党や無党派層に近づけると議席数がどう増減するか、というシミュレーションとなります。
・ グラフの元データは、画面の下のテキスト欄で定義しています。
テキスト欄の各行は、各政党支持層等に対応しています。
各行は、半角スペース区切りで以下を定義しています。
中心座標x0 標準偏差sigma1 面積area1 文字列(政党名)

最後の1行では、無党派層を定義します。必須の行です。
無党派層も正規分布で定義しますが、上記の2つめのグラフの計算の際、無党派層のプロファイルを、各党に割り振るように計算します。
無党派層を表示させたくない場合は、末尾行を “1.00 1.00 0.00 -” (面積 area1 = 0.00 とする)などとしてください。
・ テキスト欄のデータを変更した場合は、[set] ボタンをクリックしてください。グラフに反映されます。
・ たとえば、テキストボックス内で、mutoha の座標位置を x0 = 0.00 → -2.00 に変え、[set] ボタンをクリックしてください。無党派層を動かし、議席数を計算できます。
また、標準偏差 sigma1、面積 area1 の値も変更してみてください。要素(政党)の追加・削除も可能です。
・ 縦軸のスケールを変えたい場合は、y1_scale、y2_scale の値を変更してください。
・ 横軸のスケールを変えたい場合は、x1_max の値を変更してください。グラフは、-x1_max から +x1_max の範囲で描画します。
・ 議席数は total1 で設定してください。サンプルでは、議席数としていますが、店舗ごとの来客数、製品ごとの販売数などの設定も可能と思います。

計算処理の説明

・ まず、1つめのグラフで、テキスト欄で定義された内容から、各政党支持層と無党派層の標準偏差のグラフを生成しています。
・ つぎに、2つめのグラフで、各標準偏差のグラフを合計し、全体のプロファイル(赤ライン)を生成しています。
なお、無党派層のグラフは、1つめのグラフでも、2つめのグラフでも表示しています。
・ つぎに、1つめのグラフで、隣り合う政党のピーク位置の中間の座標を計算し、2つめのグラフでライン(矩形)を描いています。
・ この矩形の範囲内にあるプロファイルについて積分計算をすることで、面積(得票数)を求めます。
・ この各党の積分計算した面積の比率で、議席総数 total1 を配分することで、各政党の獲得議席を求めます。
・ JavaScript のプログラムを末尾に貼っておきますので、アレンジしたい方は流用してみてください。

数値データの作り方の例

2021年、2024年の衆院選を例に、上記の数値データの作り方の手順の概略をまとめておきます。
① 過去の獲得議席数のデータを集めます。
② Excel などを使って、各党の議席数を総議席数で割って、各党の占有率を求めます。
もし無党派層を 0% としてよいのであれば、この占有率が前記の area1 に対応します。
③ 無党派層は 50% 前後はあるといわれています。そこで、②の占有率を 1/2 倍して各党の area1 を設定します。つぎに、無党派層を占有率 50% (area1 = 0.500)として、末尾で定義します。
なお、各 area1 の値について、相対的な面積をから得票率を計算後、議席数 total1 を割り振ることになります。したがって、各 area1 の値を一斉に n 倍しても、結果は同じになります。
④ Excel などを使って、2021年から2024年で、各党の議席の増減を求めます。
つぎに暫定で、2021年の無党派層を中央 x=0 に配置します。2021年から2024年にかけ、無党派層が左側にシフトする(x = 0.00 → -2.00)とします。
すると、正規分布の形から、議席数の増減が大きい政党が x = 0 の原点近くに位置する(※1)ことになります。また、議席が増加する政党が x < 0 の領域に、議席が減少する政党が x > 0 の領域に配置する(※2)ことになります。
そこで、(※1)、(※2)を満たすように、たとえば、x0 = -8, -6, -4, -2, 0, 2, 4, 8 などと適当な間隔をあけて各政党を配置していきます。
⑤ パラメータを設定したら、[set] ボタンをクリックし、議席数を確認します。
⑥ 2021年、2023年の結果がうまく再現できるように、各座標位置、面積、標準偏差を調整しつつつ、値を探します。

※ 本来、④~⑥の最適化の部分については、Python などで書くことも可能ですが、モデルの妥当性を視覚的に把握することを優先したいです。そこで、JavaScript で可視化するところを作成し、モデルの妥当性をまずは直感的に確認することにしています。

わかること

・ 上記のシミュレータで、無党派層 mutoha を、自民党側から立憲民主党側にスライドさせる(x0 = 0.00 → -2.00 とする)と、2021年の衆議院選挙、2024年の衆議院選挙の結果を有効1桁~2桁程度で再現できることがわかります。
・ 現実の結果とは、10議席以下程度のずれがあり、パラメータを微調整すればさらに最小化できる可能性があります。今回は、モデルの妥当性が確認できれば十分(時間をかけすぎても無駄)ですので、打ち切ってスクリプトを公開することにしています。
・ 上記のシミュレータを使って、与党第一党、野党第一党、無党派層の3つの正規分布からなるシンプルなモデルを設定すると、よくいわれている現象の多くが再現できます。
・ たとえば、与党第一党と野党第一党は票数を集めているので、無党派層の中心は与党第一党と野党第一党の間にあって、与党よりにあると考えるのが自然です。
そこで、野党第一党の政策(ポジション)を与党側(無党派層の中心側)に寄せると、獲得議席が増加する様子を再現できます。
逆に、野党第一党のポジション(政策)を与党ではなく他の野党側(獲得議席が少ない側)に寄せる(共闘する)と、無党派層の中心から遠ざかることになり、獲得票数を下げることになります。これは、2024年の東京都知事選などで見られた現象です。
逆に、2024年衆院選のように、野党第一党と与党第一党が互いに政策的に似た党首になり、与野党の政策が似てくると、野党第一党としては、獲得議席の増加が見込めることになります。(小さい側が、大きい側や無党派の中心位置に寄せると浮動票の獲得が見込めるため有利といえる。逆に、与党側としては、小さい側に寄せると自分に乗っていた無党派層の票が相手に奪われる可能性が出てくるため、注意が必要といえる。もし互いに与野党が近づくのであれば、やっていることは同じになるので、政界再編が生じやすくなる方向性といえる。)
・ また、少数野党で、スクープを連発して与党を攻撃する場合、与党を支持していた無党派層が流出し、票がばらけることになります。しかし、現状の投票結果から無党派層の範囲(sigma1)を推測すると、少数野党の位置が無党派層の中心から遠いことがわかります。
すると少数野党としてはスクープによる攻撃はしたものの議席獲得にはつながりにくく、票がばらけることで無党派層の中心に近いところでポジショニングを取っている他党を利するだけになる、といった現象も理解することができます。
・ また、上記のモデルでは、標準偏差 sigma1 の設定により、正規分布の裾野が、隣接する他党に入り込むことがあります。よく「票固めをする」という表現がありますが、「票固めをする」とは、上記のモデルで標準偏差 sigma1 を小さくすることに対応するといえそうです。
・ 一例として、最近のニュースでは、与党は支持層の6割程度しか票固めができなかったという報道があります。
上記のモデルでいえば無党派層がまず逃げて、残った自党の正規分布のうち、4割前後が他党に逃げたことになります。得票は、上記のシミュレータの2番目のグラフの矩形領域内となるため、正規分布のすそ野は、矩形領域の外側に逃げていることになります。ここから、標準偏差 sigma1 の値についても、他党より広めになっている等が推測でき、各報道で出てくる断片的なニュースの背景を推測していくことが可能です。
・ また、上記の衆院選の結果で、左側に左寄りの政党、右側に右寄りの政党となっているとわかりやすいですが、実際には混在しています。つまり、無党派層の動きを1つの正規分布で近似すると、今回の無党派層の動きは、一般的な右寄り、左寄りという指標で動いているのではなく、既存の与党が見限られたなど、別のモノサシで動いているといえそうです。
・ 上記以外にも、多くの示唆を得ることが可能ですが収拾がつかなくなるため、これ以降は省略することにします。

まとめ

投票行動を正規分布でモデリングし、実際の投票結果を把握する試みを行いました。

議席数の増減などが傾向として再現でき、正規分布として定量的な把握が可能と考えられます。
正確な数値を求めるには、Python などでベストフィットの計算などをすれば、より詳細な検討も可能そうです。

今回は投票行動のモデリングを行いましたが、一般的なマーケティング分析も実質的に同じです。
上記の政党を、各店舗、各製品、ウェブサービスなどに置き換え、実際の購買データなどから、固定客、浮動客、ユーザー動向などを分析できそうです。
どこかの大学や企業などでやっていてもよさそうな内容ですが、ネット検索をする限り、近いものが見つかりませんでした。そこでモデリングをし、ざっと状況を見積もってみました。

各政党の綱領の文字列などから、各政党間の相関係数のマトリックスを求める。相関係数のマトリックスを scikit-learn などでクラスタリングして2次元平面に落とし込んで、各政党間の距離・位置関係を求める。今回の政党支持層と無党派層を2次元平面上でマッピングし、投票行動を可視化すれば有権者の潜在的な需要や動きを可視化できるだろうか。。

関連リンク
・ 任意の単語間の相関係数を求めてみる 【Python & Google Trends】
・ 機械学習で株価予測 【Python】

サンプルスクリプト

正規分布シミュレータ normal_distribution_simulator1.html


<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>normal_distribution v0.1</title>
</head>
<body>
normal distribution simulator v0.1 <br>

    <canvas id="canvas1" width="800" height="200" style="border:1px solid #000000;"></canvas>
    <canvas id="canvas2" width="800" height="200" style="border:1px solid #000000; margin-top: 20px;"></canvas><br>

    y1_scale:<input type="text" id="textbox01" style="width:60px; height:14px;" value="4.0" onchange="set1()">
    y2_scale:<input type="text" id="textbox02" style="width:60px; height:14px;" value="3.0" onchange="set1()">
    x1_max:<input type="text" id="textbox03" style="width:60px; height:14px;" value= "10" onchange="set1()">
    total1:<input type="text" id="textbox04" style="width:60px; height:14px;" value="465" onchange="set1()">

    <div id="result1">-</div><br>
    <button onclick="ini1()">initialize</button>
    <button onclick="set1()">set</button><br> 
    <textarea id="textarea1" rows="14" cols="50" placeholder=""></textarea><br>

    <div id="result2">-</div>
    <div id="result3">-</div>

<script>

const canvas1 = document.getElementById('canvas1');
const canvas2 = document.getElementById('canvas2');
const result1 = document.getElementById('result1');
const result2 = document.getElementById('result2');
const contxt1 = canvas1.getContext('2d');
const contxt2 = canvas2.getContext('2d');

let y1_scale = 1.0; 
let y2_scale = 1.0; 
let x1_max = 10.0; 
let total1 = 465; 
let dragging1 = null;

let distributions1 = null; 
let rects1 = null; 

let totalProfile1 = new Array(canvas1.width).fill(0);
let totalProfile2 = new Array(canvas1.width).fill(0);

window.onload = onload1; 

function onload1() {
    ini1(); 
} 

function ini1() { 
    let str1 = `
-6.70 0.50 0.000 hosyu 
-6.50 0.50 0.001 sansei 
-5.40 0.50 0.007 reiwa 
-5.10 0.50 0.006 kokumin
-3.00 0.75 0.050 rikken
 0.90 0.75 0.320 jimin
 3.00 0.75 0.020 ishin
 3.10 0.50 0.070 komei 
 5.70 0.50 0.015 kyosan 
 7.00 0.50 0.030 syoha 
 8.50 0.50 0.001 syamin 
-2.00 2.00 0.600 mutoha
`.trim(); 

    document.getElementById('textarea1').value = str1; 

    set1(); 
} 

function set1() { 
    y1_scale = parseFloat(document.getElementById('textbox01').value); 
    y2_scale = parseFloat(document.getElementById('textbox02').value); 
    x1_max   = parseFloat(document.getElementById('textbox03').value); 
    total1   = parseInt(document.getElementById('textbox04').value); 
    let str1 = document.getElementById('textarea1').value; 
    distributions1 = parseText1( str1 ); 

    calculateDistributions1();
} 

function unscaleX1(x1) { 
    return parseInt(((x1 + x1_max) / (2.0*x1_max)) * (canvas1.width-1));
} 

function scaleX1(x1) {
    return (x1 / (canvas1.width-1)) * (2.0*x1_max) - x1_max;
} 

function gaussian1(x1, x0, sigma1) {
    return (1.0 / (sigma1 * Math.sqrt(2.0 * Math.PI))) * Math.exp(-0.5 * Math.pow((x1 - x0) / sigma1, 2));
} 

function parseText1(text1) {
    const lines1 = text1.replace('  ', ' ').trim().split('\n');
    let obj1 = lines1.map(line1 => {
        const elements = line1.trim().split(' ');
        const x0 = parseFloat(elements[0]);
        const sigma1 = parseFloat(elements[1]);
        const area1  = parseFloat(elements[2]);
        const label1 = elements[3]; 
        return { x0, sigma1, area1, label1 };
    });
    return obj1; 
};

function generate_rects1() { 
    let a1 = []; 
    let area1 = 0.0; 
    let area2 = 0.0; 
    let x1 = -x1_max; 
    for (let i1 = 0; i1 < distributions1.length-2; i1++) { 
        let x21 = distributions1[i1  ].x0; 
        let x22 = distributions1[i1+1].x0; 
        let x2 = (x21+x22)/2.0; 
        a1.push( { x1, x2, area1, area2 } ); 
        x1 = x2; 
    } 
    let x2 = x1_max; 
    a1.push( {x1, x2, area1, area2 } ); 
    rects1 = a1; 
} 

function color1( n1 ) { 
    color0 = [ "#ff0000", "#ffa500", "#daa520", "#008000", "#0000ff", "#4b0082", "#8a2be2", "#db7093", "#a52a2a", "#696969", "#008b8b", "#ff00ff" ]; 
    n0 = n1 % color0.length; 
    return color0[n0]; 
} 

function profile1(n0, x1) { 
    return distributions1[n0].area1 * gaussian1(scaleX1(x1), distributions1[n0].x0, distributions1[n0].sigma1); 
} 

function calculateTotalProfile1() { 
    let totalProfile01 = new Array(canvas1.width).fill(0); 
    let totalProfile02 = new Array(canvas1.width).fill(0); 
    for (let n0 = 0; n0 < distributions1.length; n0++ ) { 
        for (let x1 = 0; x1 < canvas1.width; x1++) {
            totalProfile01[x1] += profile1( n0, x1 ); 
        } 
    } 
    for (let x1 = 0; x1 < canvas1.width; x1++) {
        totalProfile02[x1] += profile1( distributions1.length-1, x1 ); 
    } 
    totalProfile1 = totalProfile01; 
    totalProfile2 = totalProfile02; 
} 

function calculateDistributions1() { 
    generate_rects1(); 
    calculateTotalProfile1(); 

    contxt1.clearRect(0, 0, canvas1.width, canvas1.height);
    contxt2.clearRect(0, 0, canvas2.width, canvas2.height);

    drawRectangles1(); 

    distributions1.forEach((distribution1, index1)  => {
        contxt1.beginPath();
        contxt1.strokeStyle = color1(index1);
        contxt1.moveTo(0, 0); 
        for (let x1 = 0; x1 < canvas1.width; x1++) {
            const scaledX = scaleX1(x1);
            const y1 = canvas1.height * (1.0 - y1_scale * distribution1.area1 * gaussian1(scaledX, distribution1.x0, distribution1.sigma1));
            contxt1.lineTo(x1, y1);
        }
        contxt1.stroke();
        contxt1.fillStyle = color1(index1);
        contxt1.font = '14px Arial';
        let str1 = distribution1.label1; 
        let textWidth1 = contxt2.measureText(str1).width; 
        let x1 = unscaleX1(distribution1.x0) - textWidth1/2.0; 
        contxt1.fillText(str1, x1, canvas1.height - 10);
    });


    contxt2.beginPath();
    contxt2.moveTo(0, 0); 
    contxt2.strokeStyle = '#ff0000';
    for (let x1 = 0; x1 < canvas1.width; x1++) {
        const y1 = canvas2.height * (1.0 - y2_scale*totalProfile1[x1]);
        contxt2.lineTo(x1, y1);
    }
    contxt2.stroke();

    contxt2.beginPath();
    contxt2.moveTo(0, 0); 
    contxt2.strokeStyle = color1(rects1.length);
    for (let x1 = 0; x1 < canvas1.width; x1++) {
        const y1 = canvas2.height * (1.0 - y2_scale*totalProfile2[x1]);
        contxt2.lineTo(x1, y1);
    }
    contxt2.stroke();

} 

function drawRectangles1() { 
    calculateArea1();
    rects1.forEach((rect1, index1) => {
        contxt2.strokeStyle = color1(index1);
        contxt2.lineWidth = 1; 
        contxt2.strokeRect(unscaleX1(rect1.x1) + 2, 0, unscaleX1(rect1.x2) - unscaleX1(rect1.x1) - 2, canvas2.height);
        let str1 = distributions1[index1].label1; 
        let str2 = (rects1[index1].area1*total1).toFixed(0); 
        let textWidth1 = contxt2.measureText(str1).width; 
        let textWidth2 = contxt2.measureText(str2).width; 
        let x1 = (unscaleX1(rect1.x1)+unscaleX1(rect1.x2))/2.0-textWidth1/2.0; 
        let x2 = (unscaleX1(rect1.x1)+unscaleX1(rect1.x2))/2.0-textWidth2/2.0; 
        contxt2.fillStyle = color1(index1);
        contxt2.font = '14px Arial';
        contxt2.fillText(str1, x1, 20);
        contxt2.fillText(str2, x2, 40);
    });
} 

function calculateArea0( n1, totalProfile0 ) { 
    let area0 = 0.0; 
    for (let x1 = Math.min(unscaleX1(rects1[n1].x1), unscaleX1(rects1[n1].x2)); x1 <= Math.max(unscaleX1(rects1[n1].x1), unscaleX1(rects1[n1].x2)); x1++) {
        area0 += totalProfile0[x1];
    } 
    area0 = area0*((2.0*x1_max)/canvas1.width); 
    return area0; 
} 

function calculateArea1() { 
    let rects_area1 = new Array(rects1.length).fill(0); 
    let rects_area2 = new Array(rects1.length).fill(0); 
    let sum1 = 0.0; 
    let sum2 = 0.0; 
    for (let n0 = 0; n0 < rects1.length; n0++) { 
        let val1 = calculateArea0( n0, totalProfile1 ); 
        sum1 = sum1 + val1; 
        rects_area1[n0] = val1; 

        let val2 = calculateArea0( n0, totalProfile2 ); 
        sum2 = sum2 + val2; 
        rects_area2[n0] = val2; 

    } 
    for (let n0 = 0; n0 < rects1.length; n0++) { 
        rects1[n0].area1 = rects_area1[n0]/sum1; 
        rects1[n0].area2 = rects_area2[n0]/sum1; 

    } 
    let str1 = ""; 
    for (let n0 = 0; n0 < rects1.length; n0++) { 
        str1 = str1 + distributions1[n0].label1 + ": " + (rects1[n0].area1*total1).toFixed(0) + " "; 
        str1 = str1 + rects1[n0].area1.toFixed(3) + " "; 
        str1 = str1 + (1.0 - rects1[n0].area2/rects1[n0].area1).toFixed(3) + " "; 

    } 
    result2.textContent = str1; 
};

canvas1.addEventListener('mousedown', e => {
    const x1 = e.clientX - canvas1.offsetLeft;
    dragging1 = distributions1.find(distribution1 => Math.abs(scaleX1(x1) - distribution1.x0) < distribution1.sigma1);
});

canvas1.addEventListener('mousemove', e => {
    if (dragging1) {
        const x1 = e.clientX - canvas1.offsetLeft;
        dragging1.x0 = scaleX1(x1);
        calculateDistributions1();
    }
}); 

canvas1.addEventListener('mouseup', mouseup1);

function mouseup1() { 
    dragging1 = null; 
    let distribution0 = distributions1.pop(); 
    distributions1.sort((a1, a2) => a1.x0 - a2.x0); 
    distributions1.push(distribution0); 
    calculateDistributions1();
} 

function showProfile1(e, n1) { 
    let x1 = e.offsetX; 

    let y1 = (1.0 - e.offsetY/canvas1.height);
    if (n1 == 0) { 
        y1 = y1/y1_scale; 
    } else { 
        y1 = y1/y2_scale; 
    } 
    let str1 = " x1:" + scaleX1(x1).toFixed(2) + " y1:" + y1.toFixed(2) ; 
    let str2 = "-" 
    if ( x1 >= 0 && x1 < totalProfile1.length ) { 
        str2 = "totalProfile1[x1]:" + totalProfile1[x1].toFixed(2) + " totalProfile2[x1]:" + totalProfile1[x1].toFixed(2); 
    } 
    result1.textContent = str1 + " " + str2; 
} 

canvas1.addEventListener('mousemove', e => {
    showProfile1(e, 0); 
});

canvas2.addEventListener('mousemove', e => {
    showProfile1(e, 1); 
});

</script>
</body>
</html>


タイトルとURLをコピーしました