エルゴノミクスキーボードCharaChorder Oneのかな入力配列を作ってみた

# CharaChorderとは

[CharaChorder One](https://www.charachorder.com/en-jp/products/charachorder-one)はいわゆるエルゴノミクスキーボードで、[DataHand](https://en.wikipedia.org/wiki/DataHand)のように、指をほとんど動かさずに文字入力が可能なデバイスです。
人差し指から小指までに各1本、親指に2本の計6本のジョイスティックがメインの入力で、そのほかに3本のジョイスティックがついています。
実質的には各指にフリック入力が割り当てられている状態です。

# 初期配列

![image.png](https://www.charachorder.com/cdn/shop/files/Quick_Reference_Guide_1.jpg?v=1653958214&width=1080)

[公式ドキュメント](https://docs.charachorder.com/CharaChorder%20One.html#learning-the-layout)によると、配列は英語に対して最適化されているようです。
アメリカの会社なので当然ではありますが、配列に日本語は考慮されていません。

この配列では、かな入力はさておきローマ字入力も困難です。と、言うのもローマ字入力で最もよく使用する母音のうち4つが左手に集中している上、「i」と「o」が同じ指に割り当てられています。これでは記憶(kioku)のようなiとoが連続するような単語の入力にまごつくのは必至でしょう。

# 同時入力による入力の簡略化

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/685051/50351531-6a61-121a-d5bf-b79f8211ae73.png)

さらにローマ字入力にとって悪いことに、このキーボードにはキーを同時に入力することで、事前に指定した単語を入力してくれる機能もあります。
例えば、「きのう」の三文字を入力しようとしたとき、かな入力では「きの」の二文字の入力で済みますが、ローマ字では「kinou」と同時入力したとしても「おにく」(oniku)と区別できません。少し極端な例ではありますが、入力文字数が増えるぶん、アナグラムのパターンも増えることは想像できるでしょう。

この2点から、かな入力用配列を作ることに決めました。

# 最初のかな入力

かな入力用の配列を作る上で、まず最もシンプルな例を考えます。
このキーボードは1つのジョイスティックにつき4方向+押し込みの5入力をサポートしています。これが片手6本、両手で12本あるので、五十音に記号を含めても十分足ります。1つのジョイスティックがそのままフリック入力に変わるようなものです。
フリック入力に慣れていれば、習得も容易いでしょう。

一方、この配列では「いう」「かく」「なので」などの頻出する言葉が同じ指に来てしまい、入力が快適には思えません。

# かな入力配列の最適化

## データの準備

これを踏まえ、キーボード配列に必要な要素を考えます。

- 頻出する言葉が同じ指に集中しないこと
- 押しやすいキー位置には使用頻度の高い文字を使用すること

この2つの要素を同時に考慮し、最適化してみます。

頻出する言葉が同じ指に集中しないこと、というのは、すなわち同じ指で押すキーの中に頻出する言葉が少ないことです。これは例えば5キーの中から2キー選び、その2キーが文章中でどれだけ使用されたか確認すれば調査できます。

押しやすいキー位置には使用頻度の高い文字を使用すること、もほとんど同様で、文章中の使用回数に何かしらスケールをかけてあげればいいです。

この頻度のデータはドンピシャなものがあり、[N-gram](https://en.wikipedia.org/wiki/N-gram)と呼ばれています。これは簡単にまとめると、「たんたんめん」という文字を変換する場合、2-gramなら「たん」「んた」「たん」「んめ」「めん」に分解されます。1-gramなら単に文字の頻度となり、まさしく我々が求めているデータです。

[月見草開発に用いた文章サンプル](https://w.atwiki.jp/keylay/pages/16.html)に4-gram、1180万文字のデータがあり、これを今回使用させていただきました。

実際にデータを使う前に、もう一つ考えることがあります。濁音、半濁音、小字の扱いです。
こういった頻度データは多く言語解析を目的としているので、濁音などは分解されないままのデータになっています。
しかし、かな入力においては「だ」は「た」「゛」の2キーで入力します。
つまり、先にあった2-gramや1-gramのデータも、入力するキーに合わせて変換しなければなりません。
例えば「だよ」の頻度が100回、「たよ」が10回だった場合、「たよ」を110回とカウントするなどの工夫が必要です。この変換にも議論の余地はありますが、将来的にこれは「゛たよ」の3文字同時入力で扱われることを考えれば、影響は限定的でしょう。

これを踏まえ、スコアを計算するコードを示します。

```py
with open('bigram.tsv', 'r',encoding='utf-8') as f:
    lines = f.readlines()
    bigrams = {}
    for line in lines:
        key, freq = line.strip().split('\t')
        bigrams[key] = int(freq)

dakuon_to_seion_table = {
    "ぁ": "あ",
    "ぃ": "い",
    "ぅ": "う",
    "ぇ": "え",
    "ぉ": "お",
    "ゃ": "や",
    "ゅ": "ゆ",
    "ょ": "よ",
    "ー": "ほ",
    "・": "め",
    "を": "わ",
    "っ": "つ",
    "、": "ね",
    "。": "る",
    "（": "ら",
    "）": "せ",
    "「": "ら",
    "」": "せ",
    "が": "か",
    "ぎ": "き",
    "ぐ": "く",
    "げ": "け",
    "ご": "こ",
    "ざ": "さ",
    "じ": "し",
    "ず": "す",
    "ぜ": "せ",
    "ぞ": "そ",
    "だ": "た",
    "ぢ": "ち",
    "づ": "つ",
    "で": "て",
    "ど": "と",
    "ば": "は",
    "び": "ひ",
    "ぶ": "ふ",
    "べ": "へ",
    "ぼ": "ほ",
    "ぱ": "は",
    "ぴ": "ひ",
    "ぷ": "ふ",
    "ぺ": "へ",
    "ぽ": "ほ",
}

new_bigrams = {}
for key, freq in bigrams.items():
    for c in key:
        if c in dakuon_to_seion_table:
            key = key.replace(c, dakuon_to_seion_table[c])
    if key in new_bigrams:
        new_bigrams[key] += freq
    else:
        new_bigrams[key] = freq
bigrams = new_bigrams
del new_bigrams

# unigramsのロードは省略。同様に処理
```

# 最適化

上記のデータをもとに最適化を行います。まず最適化に使用するコスト計算のコードを示します。これは上で記載した条件に基づいてスコアを計算するもので、解説する箇所がないので適当に読んでください。

```py
# テキストをグループに分割
# 'あいうえお' -> [('あ', 'い'), ('う', 'え'), ('お')]のような変換をする。
# ただし上の結果はgroup_sizes = [2, 2, 1]の場合
def text_to_group(text):
    group_sizes = [3, 5, 5, 10, 3, 5, 5, 10, 1]
    index = 0
    group = []
    for size in group_sizes:
        group.append(text[index:index+size])
        index += size
    return group

# 一つのグループ（('あ', 'い')）に対し、スコア（頻度）と最も頻度の高かったペアを返す
def group_to_score(group):
    pairs = permutations(group, 2)
    total = 0
    max_score = 0
    max_text = ''
    for pair in pairs:
        bigram = ''.join(pair)
        if bigram in bigrams:
            total += bigrams[bigram]
            if bigrams[bigram] > max_score:
                max_score = bigrams[bigram]
                max_text = bigram
    return total, max_score, max_text

# キー位置をスコアに変換。スコアは低いほどよい
def position_to_score(text):
    # キー位置にかけるコスト。小さいほど押しやすいキー
    position_score_multiply = [
                           # 人差し指
                           1e-2, 1.5e-2, 1.8e-2, 
                           # 中指
                           1e-2, 1.5e-2, 1.8e-2, 2e-2, 5e-2,
                           # 薬指
                           1.2e-2, 1.6e-2, 2e-2, 2.3e-2, 8e-2,
                           
                           # 親指
                           1.8e-2, 1.8e-2, 1.8e-2, 1.8e-2,
                           3e-2, 3e-2, 3e-2, 3e-2, 5e-2, 5e-2,
                           
                           # 人差し指
                           1e-2, 1.5e-2, 1.8e-2, 
                           # 中指
                           1e-2, 1.5e-2, 1.8e-2, 2e-2, 5e-2,
                           # 薬指
                           1.2e-2, 1.6e-2, 2e-2, 2.3e-2, 8e-2,
                           
                           # 親指
                           1.8e-2, 1.8e-2, 1.8e-2, 1.8e-2, 
                           3e-2, 3e-2, 3e-2, 3e-2, 5e-2, 5e-2,

                           1e-1,
                           ]
    total = 0.0
    for i, c in enumerate(text):
        if c in unigrams:
            total += unigrams[c] * position_score_multiply[i]
    return total

# textのキー配列のスコアを計算
def all_group_score(text):
    group = text_to_group(text)
    score_max = 0
    total = 0
    max_text = ''
    for subgroup in group:
        score, max_score, current_text = group_to_score(subgroup)
        total += score
        if max_score > score_max:
            score_max = max_score
            max_text = current_text
    position_score = position_to_score(text)
    total += position_score
    return total, score_max, max_text
```

上記のスコアを最小化するような配列を探索します。できれば全配列を探索したいですが、計算コストがすごいことになるので、適当に貪欲に探索します。
最小スコアを更新したら、そのときの最悪コストのペアのどちらかをペア以外と交換したものをすべて探索キューに入れます。
この方法では初期状態によって結果が大きく変わるので、複数のシードで計算することで、できるだけ結果を均します。

```py
# 各グループ内の配置を最適化します。現状は位置スコアをグループごとにソート済みのため、単にソートで対応
def optimize_group_order(groups):
    best_orders = []
    for i, group in tqdm(enumerate(groups)):
        # 単純に、最も頻度が高い順にソートする
        sorted_group = sorted(group, key=lambda x: unigrams[x], reverse=True)
        best_orders.append(sorted_group)
    return best_orders

# 最適化を行う
def process(ind):
    hiragana = 'あいうえおかきくけこさしすせそたちつてとなにぬねのはひふへほまみむめもやゆよらりるれろわん゛゜'
    base_text = list(hiragana)
    np.random.shuffle(base_text)
    base_text = ''.join(base_text)
    if ind == 0:
        # 現状の最適な配列。単に結果表示用
        base_text = 'いくりかよこも゜しすおちひとさけせふそゆえへむてたなんうるつら゛のにまわねきほやあれめみろぬは'

    best_score = 10000000000
    best_text = ''
    group_queue = [base_text]
    while len(group_queue) > 0:
        text = group_queue.pop(0)
        total, score_max, max_text = all_group_score(text)
        # スコアが小さくなるように探索
        if total < best_score:
            best_score = total
            best_text = text
            # max_textは2文字の組み合わせのため、この中の1文字づつ入れ替えて探索
            for i in range(len(max_text)):
                for j, c in enumerate(text):
                    if c not in max_text:
                        new_text = list(text)
                        index_to_swap = new_text.index(max_text[i])
                        new_text[index_to_swap] = c
                        new_text[j] = max_text[i]
                        group_queue.append(''.join(new_text))
        if len(group_queue) > 10000:
            print('big queue, break', base_text)
            break
    return best_score, best_text


if __name__ == '__main__':
    translate_table = {
    'い': 'e',
    'ん': 'y',
    'う': '4',
    'し': 'd',
    'か': 't',
    'の': 'k',
    'た': 'q',
    'と': 's',
    'な': 'u',
    'に': 'i',
    'て': 'w',
    'く': 'h',
    'は': 'f',
    'す': 'r',
    'ま': 'j',
    'き': 'g',
    'こ': 'b',
    'つ': 'z',
    'る': '.',
    'も': 'm',
    'よ': '9',
    'り': 'l',
    'ら': 'o',
    'お': '6',
    'あ': '3',
    'を': ')',
    'さ': 'x',
    'ち': 'a',
    'れ': ';',
    'せ': 'p',
    'け': '\'',
    'そ': 'c',
    'え': '5',
    'わ': '0',
    'ゆ': '8',
    'み': 'n',
    'ろ': '`',
    'め': '/',
    'ひ': 'v',
    'や': '7',
    'ほ': '-',
    'ね': ',',
    'ふ': '2',
    'む': '\\',
    'へ': '=',
    'ぬ': '1',
    '゛': '[',
    '゜': ']',
    }

    multiprocessing.freeze_support()
    multiprocessing.set_start_method('spawn')
    #np.random.seed(42)
    all_best_score = 10000000000
    all_best_text = ''

    # 8コアで並列化
    results = []
    with multiprocessing.Pool(8) as pool:
        # 3000の違うシードで計算する
        results = pool.map(process, range(3000))
    for r in results:
        if r[0] < all_best_score:
            all_best_score = r[0]
            all_best_text = r[1]
    print(all_best_score, all_best_text)

    # グループ内の位置を最適化
    group = text_to_group(all_best_text)
    optimized = optimize_group_order(group)
    print(optimized)

    # 設定に入力するべきアルファベット・記号で表示
    for group in optimized:
        chars = []
        for c in group:
            chars.append(translate_table[c])
        print(''.join(chars))
```

# 結果

上記のコードを実行して得られた結果が以下です。

最適化済み

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/685051/249a9eff-5578-26a1-88ab-2b21aa4016f7.png)

アルファベット

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/685051/304289da-8dbc-a94e-577e-8c351012a9f5.png)

日本語の結果表示は、レイアウト設定画面のコンソールで以下のコードを実行しました。
```js
var reverseTable = {
    'e': 'い',
    'y': 'ん',
    '4': 'う',
    'd': 'し',
    't': 'か',
    'k': 'の',
    'q': 'た',
    's': 'と',
    'u': 'な',
    'i': 'に',
    'w': 'て',
    'h': 'く',
    'f': 'は',
    'r': 'す',
    'j': 'ま',
    'g': 'き',
    'b': 'こ',
    'z': 'つ',
    '.': 'る',
    'm': 'も',
    '9': 'よ',
    'l': 'り',
    'o': 'ら',
    '6': 'お',
    '3': 'あ',
    ')': 'を',
    'x': 'さ',
    'a': 'ち',
    ';': 'れ',
    'p': 'せ',
    '\'': 'け',
    'c': 'そ',
    '5': 'え',
    '0': 'わ',
    '8': 'ゆ',
    'n': 'み',
    '`': 'ろ',
    '/': 'め',
    'v': 'ひ',
    '7': 'や',
    '-': 'ほ',
    ',': 'ね',
    '2': 'ふ',
    '\\': 'む',
    '=': 'へ',
    '1': 'ぬ',
    '[': '゛',
    ']': '゜',
};

var textElements1 = document.querySelectorAll('body > div > main > section > div > svg > g > g > text');
var textElements2 = document.querySelectorAll('body > div > main > section > div > svg > g > text');

// 各要素のテキストを書き換える
function replaceText(elements) {
    elements.forEach(function(element) {
        var originalText = element.textContent;
        var newText = reverseTable[originalText];
        if (newText) {
            element.textContent = newText;
        }
    });
}

replaceText(textElements1);
replaceText(textElements2);
```

# 考慮事項・今後の拡張余地

- 今回は2-gramを使用したが、3-gramや4-gramまで考慮に入れても良い
- 英語の頻度を完全に無視しているため、「ひ」(v)が押し込みになってしまった。英語の頻度データを追加してもいい
- IMEの切り替えや入力言語に使うキーの配置をどうするか。小指に充てたいが、少し難しい
- 「を」の入力が難しい。「Shift+わ」で入力するのではなく、「)」を分けて登録してもいいのではないか
- この配列は完全に最適なものではない。データセットの偏り（実際にユーザーが入力するものと、インターネット上のテキストデータは異なる可能性の方が高い）もある上、最適化アルゴリズムや指ごとのコストも最適化する余地がある。結局は個人ごとに最適化するしかないため、これをほどほどの初期地点としたい
- 同時入力を考慮しきれていない。[DUP]キーをどこに配置するか決定できない。[DUP]キーは直前の入力をコピーするキーで、例えば言いにくいを同時入力用に登録するとき、「いいに」ではなく、「いに[DUP]」で登録するなどが考えられる。この場合、頻度計算としては「あ+[DUP]」を「ああ」として処理するなどの工夫がいる

[設定の共有リンク](https://manager.charachorder.com/config/layout/?import=layout%0A1%0AeJxV0ElOAlEAhOGqQvAShLiSWaCbbroZxHHNsDIBIuCEs4jIEA0Lr.CBzSMgeoD686UABAIAvqgzWVCA8bbnWBeV6Mt0xNiAiYer5_x8b5a9v7ttKSYK2lVY56yxyTobsuTIk6uCerKXhVzhqZTppu2blHaGTI4nHf.x__b.2nMvFVfwWlFFNGCdTdbYkC1XnhwVAHwS7bRpYCKr1UHB87PIMWFbyC9cB12h8gFRMgrIV1lFlbA2QHZV24yjP92sY2bNpOugoq3SCAoqZAxQUWX5Zr4RfBOZlYAnPJ2DBzzkEY_BIhMsswRWFqxyH1EhNV5.IiiM9SN_VGuMKQ3_lWKrEpPL1MzAzDkKQRGsv_kV_gABUzsE%0Aone)

ブラウザ上のタイピング練習ソフトが機能しなかったため、簡易でタイピング練習ソフトを組みました。
```rs:main.rs
use crossterm::{
    cursor,
    event::{self, Event, KeyCode, KeyEventKind, KeyModifiers},
    style::{Color, Print, SetForegroundColor},
    terminal::{self, EnterAlternateScreen, LeaveAlternateScreen},
    ExecutableCommand,
};
use rand::Rng;
use std::collections::HashMap;
use std::io::{self, stdout};

fn main() -> io::Result<()> {
    let mut rng = rand::thread_rng();

    let mut table = HashMap::new();

    for line in include_str!("dict.tsv").lines() {
        let mut parts = line.split('\t');
        if let (Some(romaji), Some(hiragana)) = (parts.next(), parts.next()) {
            table.insert(romaji.to_string(), hiragana.to_string());
        }
    }

    let keys: Vec<String> = table.keys().cloned().collect();

    let mut stdout = stdout();
    stdout.execute(EnterAlternateScreen)?;
    terminal::enable_raw_mode()?;

    let mut lines = Vec::with_capacity(2);
    let count = 10;
    loop {
        let random_indexes = (0..count)
            .map(|_| rng.gen_range(0..keys.len()))
            .collect::<Vec<usize>>();
        let random_text = random_indexes
            .iter()
            .map(|&i| table.get(&keys[i]).unwrap().as_str())
            .collect::<Vec<&str>>()
            .join("");
        if lines.len() == 2 {
            lines.remove(0);
        }
        lines.push(random_text.clone());
        // 画面をクリアして、ランダムな文字列を表示する
        stdout
            .execute(terminal::Clear(terminal::ClearType::All))?
            .execute(cursor::MoveTo(0, 0))?
            .execute(Print(lines.join("\n")))?;
        let cursor_position = cursor::position()?;
        stdout.execute(cursor::MoveTo(0, cursor_position.1))?;
        let mut inputs = Vec::new();
        loop {
            if let Event::Key(event) = event::read()? {
                match event.code {
                    KeyCode::Char('c') if event.modifiers == KeyModifiers::CONTROL => {
                        terminal::disable_raw_mode()?;
                        stdout.execute(LeaveAlternateScreen)?;
                        return Ok(());
                    }
                    KeyCode::Char(c) if event.kind == KeyEventKind::Press => {
                        inputs.push(c);
                    }
                    KeyCode::Backspace | KeyCode::Delete if event.kind == KeyEventKind::Press => {
                        inputs.pop();
                    }
                    _ => {}
                }
            }

            // 一致を確認し、合っている文字、間違っている文字に色を付ける
            // 完全に一致した場合、次の行へ移行する
            let input_text = inputs
                .iter()
                .map(|c| {
                    table
                        .get(&c.to_string())
                        .unwrap_or(&String::from("＊"))
                        .to_owned()
                })
                .collect::<Vec<String>>()
                .join("");
            if input_text == random_text {
                break;
            }
            stdout
                .execute(cursor::MoveTo(0, cursor_position.1))?
                .execute(terminal::Clear(terminal::ClearType::CurrentLine))?
                .execute(cursor::MoveTo(0, cursor_position.1 + 1))?
                .execute(terminal::Clear(terminal::ClearType::CurrentLine))?;
            let mut print_position = cursor_position;
            for (i, c) in input_text.chars().enumerate() {
                if let Some(hiragana) = &random_text.chars().skip(i).next() {
                    if c == *hiragana {
                        stdout
                            .execute(cursor::MoveTo(print_position.0, print_position.1))?
                            .execute(SetForegroundColor(Color::Green))?
                            .execute(Print(format!("{}", hiragana)))?
                            .execute(SetForegroundColor(Color::White))?;
                        stdout
                            .execute(cursor::MoveTo(print_position.0, print_position.1 + 1))?
                            .execute(Print(format!("{}", c)))?;
                    } else {
                        stdout
                            .execute(cursor::MoveTo(print_position.0, print_position.1))?
                            .execute(SetForegroundColor(Color::Red))?
                            .execute(Print(format!("{}", hiragana)))?
                            .execute(SetForegroundColor(Color::White))?;
                        stdout
                            .execute(cursor::MoveTo(print_position.0, print_position.1 + 1))?
                            .execute(Print(format!("{}", c)))?;
                    }
                } else {
                    stdout
                        .execute(cursor::MoveTo(print_position.0, print_position.1 + 1))?
                        .execute(SetForegroundColor(Color::Red))?
                        .execute(Print(format!("{}", c)))?;
                }
                print_position = cursor::position()?;
                print_position.1 -= 1;
            }
            if input_text.chars().count() < random_text.chars().count() {
                stdout
                    .execute(cursor::MoveTo(print_position.0, print_position.1))?
                    .execute(SetForegroundColor(Color::White))?
                    .execute(Print(
                        random_text
                            .chars()
                            .skip(input_text.chars().count())
                            .collect::<String>(),
                    ))?
                    .execute(SetForegroundColor(Color::White))?;
            }
            stdout.execute(SetForegroundColor(Color::White))?;
        }
    }
}
```

```text:dict.tsv
e	い
y	ん
4	う
d	し
t	か
k	の
q	た
s	と
u	な
i	に
w	て
h	く
f	は
r	す
j	ま
g	き
b	こ
z	つ
.	る
m	も
9	よ
l	り
o	ら
6	お
3	あ
)	を
x	さ
a	ち
;	れ
p	せ
\	け
c	そ
5	え
0	わ
8	ゆ
n	み
`	ろ
/	め
v	ひ
7	や
-	ほ
,	ね
2	ふ
\	む
=	へ
1	ぬ
[	゛
]	゜
```

# この配列を使って記事を書いてみた感想・所感

最初はあまりの配列のカオスさに慣れなかったが、意外と覚えるものだった。5時間くらいかかったが、まあまあいい時間だった。タイピング練習ソフトを作るくらいなら記事書くほうが練習になる。