「日本語ボキャブラリーテスト」のアルゴリズム

[語彙力診断の点数分布 - Qiita](http://qiita.com/kentskind/items/fc49952dda57c6a43260) を読んでなんとなくやってみたくなったので。当該診断のアルゴリズムを読んでいきます。

[日本語ボキャブラリーテスト](http://www.arealme.com/japanese-vocabulary-size-test/ja/)のソースコードを見ると結果表示スクリプトが

```js
function resultParse() {
  $("#collection, #progress").hide();$("#result_details, #emoji, .result_link, .footer-share").show();
  iq_value = getVocSizeByAnswers(voc_answers);
  prestige = "私の語彙力は・・・【" + parseInt(iq_value * lang_ratio) + "】です！あなたは？";
  ...
}
```
というものであるというのがわかります。`iq_value`及び`lang_ratio`は初期化部分っぽいところで

```js
<script>var lang_ratio = 1.2;
var iq_value = 0;
...
```
のように設定されており、`lang_ratio`は中身がよくわからないが（他の言語のバージョンもあるので、言語別のアジャスタブルパラメータなのかもしれない）以後他の値が代入されることもなく固定値のようです。

`iq_value`の方は `getVocSizeByAnswers(voc_answers)` の戻り値との事なので同関数を検索するとその中身は

```js
function getVocSizeByAnswers(arr){
  var size = 0;
  var H=0,L=0,M=0;
  var F = [];
  F.push([18,20,18,20,0,10,'20000+(H*.05+M*.05+L*.1)*3500']);
  F.push([18,20,14,17,0,10,'15000+(H*.05+M*.05+L*.1)*3000']);
  F.push([18,20,10,13,0,10,'8000+(H*.05+M*.05+L*.1)*1000']);
  F.push([18,20,5,9,0,10,'5000+(H*.05+M*.05+L*.1)*1000']);
  F.push([18,20,0,4,0,10,'4000+(H*.05+M*.05+L*.1)*1000']);
  F.push([15,17,18,20,0,10,'15000+(H*.05+M*.05+L*.1)*3000']);
  F.push([15,17,14,17,0,10,'15000+(H*.05+M*.05+L*.1)*1000']);
  F.push([15,17,10,13,0,10,'5000+(H*.05+M*.05+L*.1)*800']);
  F.push([15,17,5,9,0,10,'4000+(H*.05+M*.05+L*.1)*1000']);
  F.push([15,17,0,4,0,10,'3000+(H*.05+M*.05+L*.1)*1000']);
  F.push([10,14,18,20,0,10,'6000+(H*.05+M*.05+L*.1)*1000']);
  F.push([10,14,14,17,0,10,'5000+(H*.05+M*.05+L*.1)*1000']);
  F.push([10,14,10,13,0,10,'4000+(H*.05+M*.05+L*.1)*1000']);
  F.push([10,14,5,9,0,10,'4000+(H*.05+M*.05+L*.1)*800']);
  F.push([10,14,0,4,0,10,'4000+(H*.05+M*.05+L*.1)*500']);
  F.push([5,9,18,20,0,10,'10000+(H*.05+M*.05+L*.1)*1000']);
  F.push([5,9,14,17,0,10,'8000+(H*.05+M*.05+L*.1)*1000']);
  F.push([5,9,10,13,0,10,'4000+(H*.05+M*.05+L*.1)*1000']);
  F.push([5,9,5,9,0,10,'4000+(H*.05+M*.05+L*.1)*800']);
  F.push([5,9,0,4,0,10,'4000+(H*.05+M*.05+L*.1)*500']);
  F.push([0,4,18,20,0,10,'5000+(H*.05+M*.05+L*.1)*1000']);
  F.push([0,4,14,17,0,10,'4000+(H*.05+M*.05+L*.1)*1000']);
  F.push([0,4,10,13,0,10,'4000+(H*.05+M*.05+L*.1)*500']);
  F.push([0,4,5,9,0,10,'2000+(H*.05+M*.05+L*.1)*500']);
  F.push([0,4,0,4,0,10,'(H*.05+M*.05+L*.1)*500']);
  for (var i = 0; i<50; i++){
    var score = arr[i];
    if(score>0){
      if (i<20){
        H++;
      }
      if (i<40 && i>19){
        M++;
      }
      if (i>39){
        L++;
      }
    }
  }
  for (var j = 0;j<F.length; j++){
    var rule = F[j];
    var H1 = rule[0];
    var H2 = rule[1];
    var M1 = rule[2];
    var M2 = rule[3];
    var L1 = rule[4];
    var L2 = rule[5];
    var ruleQuery = rule[6];
    if (H>=H1 && H<=H2 && M>=M1 && M<=M2 && L>=L1 && L<=L2){
      size = eval(ruleQuery);
    }
  }
  size = Math.round(size);
  return size;
}
```
となっています。`arr[] = voc_answers[]` の中身がわからないと何をしているのかよくわからないので検索すると

```js
function updateValue(v) {
  if (v == 'M' || v == 'F' || v == 'N') {
    gender = v;
  } else {
    voc_answers.push(Number(v));
  }
}
```
という関数があり、これで更新しているようです。

`updateValue`を呼び出している箇所を探すと

```js
$('.answer').click(function(){
    var clickDiff = (new Date()).getTime() - lastClick;
    lastClick = (new Date()).getTime();
    if (clickDiff < LOCK_TIME && !isDebug) {
      alert('よく問題を読んでから答えて下さい！');
      return;
    }
    var $this = $(this);
    var val = $this.attr('value');
    _gaq.push(['_trackPageview', 'q-'+counter+'/'+$this.index()]);
    var a_j = val.split('to,')[1];
    var is_j = (Number(a_j) === parseInt(a_j, 10));
    var skipAll = false;
    if(is_j){val = val.split('to,')[0]}
    if(val){if(is_j){val = val.split(',').join('')}updateValue(val)}

    ......
```
というふうになっており、問題に答えたタイミングでセットされるようです。
結局`val`は選択肢パネルの`value`属性であるということがわかります。

そしてこの`value`の値なのですがおそらくその選択肢が正解か否かを表す二値が入っているのでしょう。

それに関しては推論のつながりを重視するのならこの要素がいかにして現れるかを追っていく方法がありますが、おそらくは問題文や選択肢もベタ書きのような気配なので選択肢の一つで検索してみると、

```js
var data = "ならう_SYN\n(0)つかむ\n(0)上げる\n(0)取る\n(1)学ぶ\n|\nまじめ_SYN\n(0)本当\n(1)本気\n(0)本来\n(0)本心\n| ... \nたをやめ_ANT\n(0)たまゆら\n(0)ありてい\n(0)あまつさえ\n(1)ますらを"; ...
``` 
という感じで問題の情報がひとつの変数にパイプ区切りで突っ込んであるところが見つかります。この選択肢の前の括弧に入っている数値が非常に怪しいので、Chrome のディベロッパーツールを使って先ほどの`answer`クラス`div`要素の`value`属性を調べるとこの括弧の中身と一致しました。

したがってここまでの流れをまとめると、「正解不正解情報を 0/1 [int] の配列で保有して getVocSizeByAnswersに渡している。」というものになります。

`getVocSizeByAnswers`自体を見ます。此処からPython様の擬似言語になります。

```py
  size = 0; H=0,L=0,M=0; F = [];
  F.append([18,20,18,20,0,10,'20000+(H*.05+M*.05+L*.1)*3500']);
  ...
  H = sum(arr[0:20]);
  M = sum(arr[20:40]);
  L = sum(arr[40:50]);

  for rule in F:
    H1, H2, M1, M2, L1, L2 = rule[0:6];
    ruleQuery = rule[6];
    if H1 <= H <=H2 and M1 <= M <=M2 and L1 <= L <=L2:
      size = eval(ruleQuery);
    # if in the case of rule = [18,20,18,20,0,10,'20000+(H*.05+M*.05+L*.1)*3500']
    # size calculated if H in [18, 19, 20] and M in [18, 19, 20] and L in 0 .. 10 (always true)
    # according to the formula: size = 20000 + (H * 0.05 + M * 0.05 + L * 0.1) * 3500
  }
```
のようになっています。
何をしているかというと

1. 各問題番号正解数から H（序盤の問題正解数）、M（中盤の問題正解数）、L（終盤の問題正解数）を算出
2. F の中にあるルールを前から順番に適用していく。
3. ルールは (H,M,L) の取りうる範囲とスコア計算式からなる
4. F を上から眺めていくと
    4. 序盤と中盤に関して９割以上の正解を要求するルールから始まり徐々に条件を下げていく。
    4. 終盤に関しては全ルールにわたって正解数を問わない

というようになっています。

分析としてはやや尻切れトンボなきがしますがとりあえずはどのようなアルゴリズムかということはわかったのでこの辺で筆を置きます。


### 感想レベルの話

* H * 0.05 + M * 0.05 + L * 0.1 で一見 L に重みがかかっているように見えるがこれは問題数の配分が 20, 20, 10 であることの補正なので特に後半に荷重がかけられているということはない。


* グラフではないがせめてルール番号、中央値、幅、幅と中央値の比を表だけでも
  * (H * .05 + M * .05 + L *.1 ) は [0., 3.] を動く、したがって幅は 係数×3. である。
  * 底上げ + 幅 /2 が中央値になると仮定し（序盤・中盤・終盤の各々のセクターに含まれる各問題の難易度は同じくらいと仮定）
  * 実際に表示される数値との比較を用意にするために全体を `lang_ratio (=1.2)` 倍
  
※）初稿でlang_ratioをかけていなかったのを修正

|rule num|center|width|ratio|
|---:|---:|---:|---:|
|0|30300|12600|0.415842|
|1|23400|10800|0.461538|
|2|11400|3600|0.315789|
|3|7800|3600|0.461538|
|4|6600|3600|0.545455|
|5|23400|10800|0.461538|
|6|19800|3600|0.181818|
|7|7440|2880|0.387097|
|8|6600|3600|0.545455|
|9|5400|3600|0.666667|
|10|9000|3600|0.400000|
|11|7800|3600|0.461538|
|12|6600|3600|0.545455|
|13|6240|2880|0.461538|
|14|5700|1800|0.315789|
|15|13800|3600|0.260870|
|16|11400|3600|0.315789|
|17|6600|3600|0.545455|
|18|6240|2880|0.461538|
|19|5700|1800|0.315789|
|20|7800|3600|0.461538|
|21|6600|3600|0.545455|
|22|5700|1800|0.315789|
|23|3300|1800|0.545455|
|24|900|1800|2.000000|

のようになる。

* 追加でやるとよかったかもしれなかったこと
  * 一つの可能性としてこのアルゴリズムがやっていることがIQ 算出関数（誤差関数の逆関数になる？）の得点区間別線形近似である可能性があるのでその可能性について検討
  ... 結構めんどくさそうですね。