覚えやすさを高めた日本語かな配列とその評価アルゴリズム

# 覚えやすさを高めた日本語かな配列とその評価アルゴリズム

https://github.com/Harsiharsi/scala-ga-layout.git

前回紹介した配列生成プログラムに、配列の覚えやすさを評価する機能を追加しました。加えて運指評価関数を微修正し生成した配列が以下になります。

``` コンポジション#3.1
゜くきふそ  ・むまわあ
つか Sはせ  れる゛んえ
てとこしす  っのなうい
たちーさへ  ょにゃおゅ
----------------------
゜・・・・  ・ぬをみぁ
・け Sほひ  めり゛もぇ
・。・・・  ら、ねぅぃ
・・・・・  よろやぉゆ
```

Google IME用ローマ字定義
```
1		゜
2		く
くi	ぐ
3		き
きi	ぎ
4		ふ
ふi	ぶ
ふ1	ぷ
5		そ
そi		ぞ
7		む
むe	ぬ
8		ま
まe	を
9		わ
わe	み
0		あ
あe	ぁ
q		つ
つi	づ
w		か
かi	が
かe		け
けi	げ
r		は
はi	ば
は1	ぱ
はe		ほ
ほi	ぼ
ほ1	ぽ
t		せ
せi	ぜ
せe		ひ
ひi	び
ひ1	ぴ
y		れ
れe	め
u		る
るe	り
i	゛
o		ん
んe	も
p		え
えe	ぇ
a		て
てi	で
s		と
とi	ど
とe	。
d		こ
こi	ご
f		し
しi	じ
g		す
すi	ず
h		っ
っe	ら
j		の
のe	、
k		な
なe	ね
l		う
うi	ゔ
うe	ぅ
;		い
いe	ぃ
z		た
たi	だ
x		ち
ちi	ぢ
c	ー
v		さ
さi	ざ
b		へ
へi	べ
へ1	ぺ
n		ょ
ょe	よ
m		に
にe	ろ
,		ゃ
ゃe	や
.		お
おe	ぉ
/		ゅ
ゅe	ゆ

[	「
]	」
'	・

A	A
B	B
C	C
D	D
E	E
F	F
G	G
H	H
I	I
J	J
K	K
L	L
M	M
N	N
O	O
P	P
Q	Q
R	R
S	S
T	T
U	U
V	V
W	W
X	X
Y	Y
Z	Z
```

AHK用スクリプト
``` compositionno3_1.ahk
#Persistent
#SingleInstance, Force
#NoEnv
#UseHook
#InstallKeybdHook
#InstallMouseHook
#HotkeyInterval, 2000
#MaxHotkeysPerInterval, 200
Critical			;基本的に割り込みは避ける
Process, Priority,, Realtime
SendMode, Input
SetWorkingDir %A_ScriptDir%
SetTitleMatchMode, 2
g_escape_char := {sc01A: "@", sc027: "`;", sc028: "`:", sc033: "`,", sc034: ".", sc035: "/", Q: "Q", W: "W", E: "E", R: "R", T: "T", Y: "Y", U: "U", I: "I", O: "O", P: "P", A: "A", S: "S", D: "D", F: "F", G: "G", H: "H", J: "J", K: "K", L: "L", Z: "Z", X: "X", C: "C", V: "V", B: "B", N: "N", M: "M", 1: "1", 2: "2", 3: "3", 4: "4", 5: "5", 6: "6", 7: "7", 8: "8", 9: "9", sc00B: "-"}
g_keys2sym :=[]
g_keys2sym[1] :={}
g_keys2sym[2] :={}
g_keys2sym[3] :={}
g_mikakutei :=""
Loop, Read, compositionno3_1_kana.txt
{
	global g_keys2sym
	Array := StrSplit(A_LoopReadLine, "`t")
	keys := Array[1]
	StringUpper, keys, keys
	g_keys2sym[StrLen(keys)][keys] :=Array[2]
}

fn_send(keys){
	global g_key2sym
	k :=""
	While (keys){
		l := StrLen(keys)
		If(l>3)
			l := 3
		While (l){
			StringRight, t, keys, l
			s := g_keys2sym[l][t]
			if(s)
				break
			l--
		}
		If(!l){
			s := t
			l := 1
		}
		k := s . k
		StringTrimRight, keys, keys, l
	}
	send, %k%
	Return
}
fn_noJP(){
	global g_mikakutei
	global g_windowskey
	If !(GetKeyState(Crtl,"P")||GetKeyState(Alt,"P")||GetKeyState(Shift,"P")||GetKeyState(RWin,"P"))
		Return 0
	fn_send(g_mikakutei)
	g_mikakutei := ""
	send, {Blind}{%A_ThisHotkey%}
	Return 1
}

Return

;シフト非対象キー
1::
2::
3::
4::
5::
6::
Q::
A::
D::
F::
G::
sc027::
Z::
X::
C::
V::
B::

;シフト対象キー
7::
8::
9::
sc00B::
W::
R::
T::
Y::
U::
I::
O::
P::
sc01A::
S::
H::
J::
K::
L::
N::
M::
sc033::
sc034::
sc035::
sc028::
{
	If(fn_noJP())
		Return
	global g_mikakutei
	global g_escape_char
	fn_send(g_mikakutei)
	g_mikakutei := g_escape_char[A_ThisHotkey]
	Return
}

;後置シフトキー
E::
{
	If(fn_noJP())
		Return
	global g_mikakutei
	global g_escape_char
	StringRight, t, g_mikakutei, 1
	If(t!=A_ThisHotkey)
		g_mikakutei .= A_ThisHotkey
	else{
		fn_send(g_mikakutei)
		g_mikakutei := A_ThisHotkey
	}
	Return
}

sc00C::
sc00D::
sc01B::
sc02A::
sc02B::
sc073::
Space::
Tab::
Enter::
BS::
Del::
Ins::
Left::
Right::
Up::
Down::
Home::
End::
PgUp::
PgDn::
sc029::
sc079::
sc07B::
sc070::
sc03A::
F1::
F2::
F3::
F4::
F5::
F6::
F7::
F8::
F9::
F10::
F11::
F12::
F13::
F14::
F15::
F16::
F17::
F18::
F19::
F20::
F21::
F22::
F23::
F24::
Esc::
AppsKey::
PrintScreen::
Pause::
Break::
Sleep::
Help::
CtrlBreak::
CapsLock::
ScrollLock::
NumLock::
Ctrl::
;LCtrl::
;RCtrl::
Alt::
;LAlt::
;RAlt::
Shift::
;LShift::
;RShift::
Numpad0::
Numpad1::
Numpad2::
Numpad3::
Numpad4::
Numpad5::
Numpad6::
Numpad7::
Numpad8::
Numpad9::
NumpadDot::
NumpadDel::
NumpadIns::
NumpadClear::
NumpadUp::
NumpadDown::
NumpadLeft::
NumpadRight::
NumpadHome::
NumpadEnd::
NumpadPgUp::
NumpadPgDn::
NumpadDiv::
NumpadMult::
NumpadAdd::
NumpadSub::
NumpadEnter::
Browser_Back::
Browser_Forward::
Browser_Refresh::
Browser_Stop::
Browser_Search::
Browser_Favorites::
Browser_Home::
;Volume_Mute::
;Volume_Down::
;Volume_Up::
;Media_Next::
;Media_Prev::
;Media_Stop::
;Media_Play_Pause::
Launch_Mail::
Launch_Media::
Launch_App1::
Launch_App2::
;LButton::
;RButton::
;MButton::
;XButton1::
;XButton2::
;WheelDown::
;WheelUp::
;WheelLeft::
;WheelRight::
{
	fn_send(g_mikakutei)
	g_mikakutei :=""
	send, {Blind}{%A_ThisHotkey%}
	Return
}

LWin::
RWin::
{
	fn_send(g_mikakutei)
	g_mikakutei :=""
	send, {Blind}{RWin Down}
	Return
}
LWin Up::
RWin Up::
{ 
	send, {Blind}{RWin Up}
	Return
}

```

``` compositionno3_1_kana.txt
1	[
2	h
3	g
4	2
5	c
7	]
8	j
9	{sc00B}
-	3
q	z
w	t
r	f
t	p
y	=
u	.
i	`
o	y
p	5
a	w
s	s
d	b
f	d
g	r
h	+z
j	k
k	u
l	4
;	e
z	q
x	a
c	\
v	x
b	'
n	+9
m	i
,	+7
.	6
/	+8
7e	1
8e	+{sc00B}
9e	n
-e	+3
we	:
re	-
te	v
ye	/
ue	l
oe	m
pe	+5
se	+.
he	o
je	+,
ke	,
le	+4
;e	+e
ne	9
me	{vkE2}
,e	7
.e	+6
/e	8
```


## 覚えやすさを評価する方法

濁点がつく文字を左手側にのみ、それ以外の文字を右手側に配置(特殊モーラと約物はどちら側にも置く)、また五十音のそれぞれの行の文字がまとめて置かれるようにしました。

このようにすることで、ある文字を打つ際に、まず左手と右手のどちらに意識を集中すべきかを即座に判断でき、また打ちたい文字の置かれた範囲が狭くなることで、打つべきキーを見つけるまでの時間がさらに少なくなることが期待できます。実際に使ってみて、覚えはじめの際の文字を探す負担が、ほかの配列に比べて相当に減っていることが実感できました。

ある行の文字の固まり具合は、その行のすべての文字を最短で繋ぐ線を作り、その長さによって評価します。そのアルゴリズムは以下のようになります。
1. キーボードのキーに座標を割り当て、ある行の文字に対応するすべての座標を選び取る。
2. 最初にランダムでひとつ座標を選びリストに加える。
3. その座標と、残りの座標との距離をすべて求め、一番近い座標とその距離を記録する。
4. 座標リストに座標が複数あれば、それらと選ばれていない残りの座標で直積集合を作り、一番近い座標を選び取る。  
  キーaとbをすでに選んでおり、残りがc、d、eとすると、ac、ad、ae、bc、bd、beの距離を求め、c、d、eのどれか一つを選び取る。

また、線が枝分かれしているかどうか、線が何回折れ曲がったかを調べ、直線、L字、階段状に斜めに伸びているものを高く評価します。なお、2 x nや3 x 3などの狭い範囲に文字がまとまっている場合は、線の長さと形状は無視します。


## 配列を覚えやすくする必要性。打鍵のさいの精神的負担

配列の文字の位置を覚えやすくすることは、打鍵時にキーを探すことによる精神的を減らし、ひいては速く打つことに繋がります。

慣れた状態で速くタイピングするとき、ある程度の長さの文字列を決まった手の動きで短時間で打ち終えます。つまり複数のキーを打つ一連の手の動きを多く覚えるということが、タイピングが速くなるということであり、そのような動きのさいには個々の文字の場所を一つずつ探すということはしません。一方で、あらゆる文字列の最適な打ち方をすべて覚えることが不可能である以上、文字をひとつずつ場所を探しながら打つ場面、あるいは、まとめ打ちのあと、次のまとめ打ちを思い出そうとするという場面は必ず訪れます。このとき配列の覚えやすさによって、文字を探す時間、あるいは疲労が変化するように思えます。

配列に親しんでいる人の間では、配列を覚えるというのは、まず個々の文字の場所をすべて完璧に覚え、それからまとめうちを覚えるという段階を踏む、という認識が行き渡っているように思います。つまり、どの一つの文字でも必要になれば一瞬で疲労なく打てるような状態になることができ、それが速く打つための最低限の段階だという認識です。しかし、清濁別置と清濁同置の配列をそれぞれ1年以上、タイピングゲームや文章書きで使った経験からすると、どちらにしてもそのような状態にはなりえないし、加えて、どれだけ使っても文字を探す負担は両者で大きな差があり続けると断言していいでしょう。使用した配列はそれぞれ月見草配列v2と自作のコンポジション#2(普通の清濁同置)で、どちらもタイプウェル常用XE程度ですが、ここで第一の問題としたいのは、速く打つために文字を探したり打鍵を組み立てたりする精神的負担を、集中力によって抑え込むということを、おそらくどれだけ練習しても続けなければならないということです。ここで想定しているのは、速く打とうとして気力に余裕がなくなっているときであり、しかもできるだけ集中力を使わず疲れないようにしたいと考えている状況であって、単にブラインドタッチできるようになれば十分というわけではありません。複数のキーの場所を一瞬で思い出すという作業を何度も間断なく、長時間行いつつ、それでいて疲れないようにしなければなりません。覚えにくい配列でも気力があればいくらでも速く打てるかもしれませんが、それは結局のところ、気力のある日にしか速く打てないということです。実際のところ、どの配列にせよ、タイピングを練習するほど多くのまとめ打ち動作を覚えて精神的負担が減るのは実感しましたが、まったくストレスがなくなると言えるまでには至りませんでした。精神的負担をいくらかでも減らすには配列を覚えやすくすることしかないと思われます。そして、単なる清濁同置でも、快適に使うギリギリの覚えやすさしかないと感じます。

配列を覚えやすくすることで懸念されるのは、配字の自由度が狭まり運指性が落ちることでしょう。しかし、以下の表のとおり、本プログラムで生成したコンポジション#3.1の運指性は前回のコンポジション#3を含むすべての自作配列を上回っており、評価対象の他の配列の中でも2番めにいいものとなっています。(制限がゆるいはずの過去配列より運指性がよくなっているのは、配置の自由度が狭まり探索効率がよくなったためでしょう。なお、今回施した運指評価関数の改良の結果、コンポジション#3の運指性はコンポジション#2より悪くなっています。) ここで注意してもらいたいのは、この運指性というのは、一度押した指を離さずに別の指で打鍵できるようなパターンの多さ、更にそのような打鍵パターンのさいの負担の少なさも考慮したものであり、つまり速く打鍵するのに必要な条件であろうものです。さらに言い添えておくと、月見草配列とぶな配列は清濁別置となっています。この結果から、覚えやすさを考慮しながら打鍵性能をも追求することは十分に可能と言えるでしょう

配列|適応度
-|-
[コンポジション#2](https://github.com/Harsiharsi/scala-ga-layout/blob/main/src/main/resources/layout_analyzed/compositionno2_basic_data.txt)|1.3260741716999672E7
[コンポジション#3](https://github.com/Harsiharsi/scala-ga-layout/blob/main/src/main/resources/layout_analyzed/compositionno3_basic_data.txt)|1.359877107799956E7
[コンポジション#3.1](https://github.com/Harsiharsi/scala-ga-layout/blob/main/src/main/resources/layout_analyzed/compositionno3.1_basic_data.txt)|1.2399568164999653E7
[月見草配列v2](https://github.com/Harsiharsi/scala-ga-layout/blob/main/src/main/resources/layout_analyzed/tsukimisouv2_basic_data.txt)|1.2356747185999643E7
[幸花配列](https://github.com/Harsiharsi/scala-ga-layout/blob/main/src/main/resources/layout_analyzed/yukika_basic_data.txt)|1.2838535499999706E7
[ぶな配列2.0](https://github.com/Harsiharsi/scala-ga-layout/blob/main/src/main/resources/layout_analyzed/buna2.0_basic_data.txt)|1.2860289677999761E7


## 運指評価関数の修正点

それぞれの打鍵性質に割り当てる分類をいくらか細かくしました。以前の分類では、少しでも同時打鍵できるものとそうでないものという分け方をしていたため、同時に押せなくはないが手に負担のかかる打鍵パターンも良運指として扱われていました。そこで、快適な打鍵パターンと負担のかかる打鍵パターンを明確に区別し、負担のかかるパターンは2段階に分けることとしました。そのため、負担の少ないパターンを優先しつつ、負担はあるが同時打鍵できるパターンも取りこぼさないような配列が生成できるようになっています。

現在の評価関数からすると、前回紹介したコンポジション#3は負担をかける配列ということになるので、この配列は失敗作であり使わないほうがいいでしょう。事実、これは使い始めから手に違和感がありました。現在秒間3打鍵ほどでコンポジション#3.1を打てるようになっていますが、今のところ安全に使えるものとなっています。

以下は定義された打鍵性質の種類の定義と、個々の打鍵性質の組み合わせを総合打鍵性質に変換する処理、ならびに総合打鍵性質を評価する処理です。
``` package.scala
sealed trait Chunkablity
sealed trait Chunkable extends Chunkablity
sealed trait EasilyChunkable extends Chunkable
sealed trait HardChunkable extends Chunkable
sealed trait NonChunkable extends Chunkablity { val distance: Int }

sealed trait StrokeProperty

sealed trait BasicStrokeProperty extends StrokeProperty with Ordered[BasicStrokeProperty] { ... }
case object BasicStrokeEasilyChunkable extends BasicStrokeProperty with EasilyChunkable
case object BasicStrokeHardChunkable extends BasicStrokeProperty with HardChunkable
case class BasicStrokeNonChunkable(val distance: Int) extends BasicStrokeProperty with NonChunkable

sealed trait HandShapeProperty extends StrokeProperty
case object HandShapeEasilyChunkable extends HandShapeProperty with EasilyChunkable
case object HandShapeHardChunkable extends HandShapeProperty with HardChunkable
case object HandShapeImpossiblyHardChunkable extends HandShapeProperty with HardChunkable

sealed trait FingerOrderProperty extends StrokeProperty with Ordered[FingerOrderProperty] { ... }
case object FingerOrderEasilyChunkable extends FingerOrderProperty with EasilyChunkable
case object FingerOrderHardChunkable extends FingerOrderProperty with HardChunkable

sealed trait TotalStrokeProperty
case class ChunkableStroke(
  val basicStroke: BasicStrokeProperty with Chunkable,
  val handShape: HandShapeProperty with Chunkable,
  val fingerOrder: FingerOrderProperty with Chunkable
) extends TotalStrokeProperty with Chunkable
case class NonChunkableStroke(val distance: Int) extends TotalStrokeProperty with NonChunkable
```

``` Evaluator.scala
private val totalStrokePropertyCache: MutMap[(String, String), TotalStrokeProperty] = MutMap()
def getTotalStrokeProperty(keys1: OneHandActionable, keys2: OneHandActionable): TotalStrokeProperty = {
  lazy val allKeys = OneHandKeyString(keys1 + keys2)
  lazy val basicStrokeProperty = getBasicStrokeProperty(allKeys)
  lazy val handShapeProperty = getHandShapeProperty(allKeys)
  lazy val orderProperty = chunkableOrderOrNot(keys1, keys2)
  lazy val totalStrokeProperty = (basicStrokeProperty, handShapeProperty, orderProperty) match {
    case (BasicStrokeNonChunkable(n), _, _) => NonChunkableStroke(n)
    case (b: Chunkable, h: Chunkable, o: Chunkable) => ChunkableStroke(b, h, o)
  }
  totalStrokePropertyCache.getOrElseUpdate(keys1.keys -> keys2.keys, totalStrokeProperty)
}

def evaluateTotalStrokeProperty(totalStrokeProperty: TotalStrokeProperty): Double = totalStrokeProperty match {
  case ChunkableStroke(BasicStrokeEasilyChunkable, HandShapeEasilyChunkable, FingerOrderEasilyChunkable) => 0.0
  case ChunkableStroke(BasicStrokeEasilyChunkable, HandShapeHardChunkable, FingerOrderEasilyChunkable) => 0.7
  case ChunkableStroke(BasicStrokeHardChunkable, HandShapeEasilyChunkable, FingerOrderEasilyChunkable) => 0.7
  case ChunkableStroke(BasicStrokeHardChunkable, HandShapeHardChunkable, FingerOrderEasilyChunkable) => 0.8

  case ChunkableStroke(BasicStrokeEasilyChunkable, HandShapeEasilyChunkable, FingerOrderHardChunkable) => 0.98
  case ChunkableStroke(BasicStrokeEasilyChunkable, HandShapeHardChunkable, FingerOrderHardChunkable) => 0.982
  case ChunkableStroke(BasicStrokeHardChunkable, HandShapeEasilyChunkable, FingerOrderHardChunkable) => 0.982
  case ChunkableStroke(BasicStrokeHardChunkable, HandShapeHardChunkable, FingerOrderHardChunkable) => 0.983

  case ChunkableStroke(BasicStrokeEasilyChunkable, HandShapeImpossiblyHardChunkable, FingerOrderEasilyChunkable) => 0.984
  case ChunkableStroke(BasicStrokeHardChunkable, HandShapeImpossiblyHardChunkable, FingerOrderEasilyChunkable) => 0.985
  case ChunkableStroke(BasicStrokeEasilyChunkable, HandShapeImpossiblyHardChunkable, FingerOrderHardChunkable) => 0.986
  case ChunkableStroke(BasicStrokeHardChunkable, HandShapeImpossiblyHardChunkable, FingerOrderHardChunkable) => 0.987

  case NonChunkableStroke(_) => 1.0
}
```

打鍵パターンと打鍵性質の対応は以下を参照。
- [基本打鍵列](https://github.com/Harsiharsi/scala-ga-layout/blob/main/src/main/resources/typingdatas/stroke_patterns.txt)
- [手の形](https://github.com/Harsiharsi/scala-ga-layout/blob/main/src/main/resources/typingdatas/hand_shapes.txt)
- [指の順番](https://github.com/Harsiharsi/scala-ga-layout/blob/main/src/main/resources/typingdatas/finger_orders.txt)