{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Gesture Typing \n",
"===\n",
"\n",
"Typing quickly and accurately on a smartphone screen is hard! One invention to make it easier is **gesture typing**, in which your finger can trace a **path** consisting of letter-to-letter **segments**. When you lift your finger the path (and the word) is complete. Below we see the path for the word \"hello.\" Note that the path is imprecise; it didn't quite hit the \"L\", but the word was recognized anyways, because \"Hello\" is a known word, whereas \"Hekko\", \"Hwerklo\", etc., are not.\n",
"\n",
"\n",
"\n",
"Questions About Gesture Typing\n",
"===\n",
"\n",
"My colleague Nicolas Schank examined (and answered) the question of what word has the longest path length. I mentioned this to [Shumin Zhai](http://www.shuminzhai.com/), the pioneer of gesture typing, and between the three of us we expanded the list of questions:\n",
"\n",
" 1. What words have the longest path length?\n",
" 2. What words have the highest ratio of path length to word length? \n",
" 3. What is the average segment length, over a typical typing work load?\n",
" 3. Is there a better keyboard layout to minimize the average segment length over a work load?\n",
" 4. How often are two words confused because they have similar paths?\n",
" 5. Is there a better keyboard layout to minimize confusion? \n",
" 6. Is there a better keyboard layout to maximize overall user satisfaction?\n",
"\n",
"Let's look at each of these questions, but first, let's get a rough idea for of the concepts we will need to model.\n",
"\n",
"Vocabulary\n",
"===\n",
"\n",
"We will need to talk about the following concepts:\n",
"\n",
"* **Keyboard**: We'll need to know the **location** of each letter on the keyboard (we consider only letters, not the other symbols).\n",
"* **Location**: A location is a **point** in two-dimensional space (we assume keyboards are flat).\n",
"* **Path**: A path connects the letters in a word. In the picture above the path is curved, but a shortest path is formed by connecting straight line **segments**, so maybe we need only deal with straight lines.\n",
"* **Segment**: A line segment is a straight line between two points.\n",
"* **Length**: Paths and Segments have lengths; the distance traveled along them.\n",
"* **Words**: We will need a list of allowable words (in order to find the one with the longest path).\n",
"* **Work Load**: If we want to find the average path length over a typical work load, we'll have to represent a work load: not\n",
"just a list of words, but a measure of how frequent each word (or each segment) is.\n",
"* **Confusion**: We need some measure of *whether* (or perhaps *to what degree*) the paths for two words can be confused with each other.\n",
"* **Satisfaction**: This is a very difficult concept to define. A user will be more satisfied with a keyboard if it allows for fast, accurate typing, and if it gives the user a feeling of mastery, not frustration.\n",
"\n",
"**Note**: Before we get started writing any real code, I've taken all the `import`s I will need throughout this notebook and gathered them together here:\n",
"\n",
"\n",
" "
]
},
{
"cell_type": "code",
"execution_count": 1,
"metadata": {
"collapsed": true
},
"outputs": [],
"source": [
"from collections import Counter\n",
"from statistics import mean\n",
"import matplotlib.pyplot as plt\n",
"import urllib\n",
"import itertools\n",
"import random \n",
"import re"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Representing Keyboards and Points\n",
"===\n",
"\n",
"The representation for a keyboard needs to describe the location of each of the letters. Using the principle of *\"Do the simplest thing that could possibly work,\"* I represent a keyboard as a `dict` of `{letter: point}` pairs, where there will be 26 letters, A-Z,\n",
"and each point will mark the x-y coordinates of the center of the corresponding key. In a standard keyboard the letters are not all on a strict rectangular grid; the **A** key is half way between the **Q** and **W** in the horizontal direction. I would like to have a programmer-friendly way of defining keyboard layouts. For example, a programmer should be able to write:\n",
"\n",
" Keyboard(('Q W E R T Y U I O P',\n",
" ' A S D F G H J K L ',\n",
" ' Z X C V B N M '))\n",
" \n",
"and this would be equivalent to the `dict`:\n",
"\n",
" {'Q': Point(0, 0), 'W': Point(1, 0), ...\n",
" 'A': Point(0.5, 1), 'S': Point(1.5, 1), ...\n",
" 'Z': Point(1.5, 2), 'X': Point(2.5, 2), ...}\n",
" \n",
"Note that one key width is two characters in the input to `Keyboard`. Here is the implementation:"
]
},
{
"cell_type": "code",
"execution_count": 2,
"metadata": {
"collapsed": true
},
"outputs": [],
"source": [
"def Keyboard(rows):\n",
" \"A keyboard is a {letter: location} map, e.g. {'Q':Point(0, 0), 'A': Point(0.5, 1)}.\"\n",
" return {ch: Point(x/2, y) \n",
" for (y, row) in enumerate(rows)\n",
" for (x, ch) in enumerate(row)\n",
" if ch != ' '}"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"What about `Point`? At first glance, Python does not appear to have a two-dimensional point as a builtin data type, but\n",
"on second thought, it does: `complex`. A complex number is a point in the two-dimensional complex plane;\n",
"we can use that to model the two-dimensional (x, y) plane. Because complex numbers are built in, manipulating them will be efficient. A bonus is that the distance between points `A` and `B` is simply `abs(A-B)`; easier than the usual formula involving squares and a square root. Thus, the simplest possible thing I could do to represent points is\n",
"\n",
"
\n", "Point = complex\n", "\n", "\n", "That would work fine. However, I would like to refer to the x coordinate of point `p` as `p.x` rather than `p.real`, and I would like points to display nicely, so I will do the second-simplest thing: make `Point` be a subclass of `complex` with `x` and `y` properties and a `__repr__` method:" ] }, { "cell_type": "code", "execution_count": 3, "metadata": { "collapsed": true }, "outputs": [], "source": [ "class Point(complex):\n", " \"A point in the (x, y) plane.\"\n", " def __repr__(self): return 'Point({}, {})'.format(self.x, self.y)\n", " x = property(lambda p: p.real)\n", " y = property(lambda p: p.imag)\n", " \n", "def distance(A, B):\n", " \"The distance between two points.\"\n", " return abs(A - B)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Alternative representations for points include an `(x, y)` tuple or namedtuple, or a NumPy two-element array, or a class. \n", "\n", "Alternatives for `Keyboard` include a subclass of `dict`, or a class that contains a `dict`. \n", "\n", "\n", "Now we can check that `Keyboard` works:" ] }, { "cell_type": "code", "execution_count": 4, "metadata": {}, "outputs": [ { "data": { "text/plain": [ "{'A': Point(0.5, 1.0),\n", " 'B': Point(5.5, 2.0),\n", " 'C': Point(3.5, 2.0),\n", " 'D': Point(2.5, 1.0),\n", " 'E': Point(2.0, 0.0),\n", " 'F': Point(3.5, 1.0),\n", " 'G': Point(4.5, 1.0),\n", " 'H': Point(5.5, 1.0),\n", " 'I': Point(7.0, 0.0),\n", " 'J': Point(6.5, 1.0),\n", " 'K': Point(7.5, 1.0),\n", " 'L': Point(8.5, 1.0),\n", " 'M': Point(7.5, 2.0),\n", " 'N': Point(6.5, 2.0),\n", " 'O': Point(8.0, 0.0),\n", " 'P': Point(9.0, 0.0),\n", " 'Q': Point(0.0, 0.0),\n", " 'R': Point(3.0, 0.0),\n", " 'S': Point(1.5, 1.0),\n", " 'T': Point(4.0, 0.0),\n", " 'U': Point(6.0, 0.0),\n", " 'V': Point(4.5, 2.0),\n", " 'W': Point(1.0, 0.0),\n", " 'X': Point(2.5, 2.0),\n", " 'Y': Point(5.0, 0.0),\n", " 'Z': Point(1.5, 2.0)}" ] }, "execution_count": 4, "metadata": {}, "output_type": "execute_result" } ], "source": [ "qwerty = Keyboard(('Q W E R T Y U I O P',\n", " ' A S D F G H J K L ',\n", " ' Z X C V B N M '))\n", "qwerty" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Computing Path Length\n", "===\n", "\n", "Now let's figure out the path length for a word: the sum of the lengths of segments between letters. So the path length for `'WORD'` would be the sum of the segment lengths for `'WO'` plus `'OR'` plus `'RD'`:" ] }, { "cell_type": "code", "execution_count": 5, "metadata": {}, "outputs": [ { "data": { "text/plain": [ "13.118033988749895" ] }, "execution_count": 5, "metadata": {}, "output_type": "execute_result" } ], "source": [ "W, O, R, D = qwerty['W'], qwerty['O'], qwerty['R'], qwerty['D'], \n", "distance(W, O) + distance(O, R) + distance(R, D)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Let's make a function to compute this:" ] }, { "cell_type": "code", "execution_count": 6, "metadata": { "collapsed": true }, "outputs": [], "source": [ "def path_length(word, kbd=qwerty):\n", " \"The total path length for a word on this keyboard: the sum of the segment lengths.\"\n", " return sum(distance(kbd[word[i]], kbd[word[i+1]])\n", " for i in range(len(word)-1))" ] }, { "cell_type": "code", "execution_count": 7, "metadata": {}, "outputs": [ { "data": { "text/plain": [ "13.118033988749895" ] }, "execution_count": 7, "metadata": {}, "output_type": "execute_result" } ], "source": [ "path_length('WORD')" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Let's check with a simpler example that we know the answer to:" ] }, { "cell_type": "code", "execution_count": 8, "metadata": {}, "outputs": [ { "data": { "text/plain": [ "4.0" ] }, "execution_count": 8, "metadata": {}, "output_type": "execute_result" } ], "source": [ "path_length('TO')" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "That makes it clearer—the **O** is four keys to the right of the **T**, on the same row, so the distance between them is 4.\n", "\n", "Here's another one that you can verify on your own:" ] }, { "cell_type": "code", "execution_count": 9, "metadata": {}, "outputs": [ { "data": { "text/plain": [ "True" ] }, "execution_count": 9, "metadata": {}, "output_type": "execute_result" } ], "source": [ "path_length('TYPEWRITER') == 1 + 4 + 7 + 1 + 2 + 4 + 3 + 2 + 1 == 25" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Question 1: Longest Path Length?\n", "===\n", "\n", "To know what the longest word is, we'll have to know what the allowable words are. The so-called TWL06 word list gives all the words that are legal in the game of Scrabble; that seems like a reasonable list (although it omits proper nouns). Here's how to load a copy:" ] }, { "cell_type": "code", "execution_count": 10, "metadata": { "collapsed": true }, "outputs": [], "source": [ "! [ -e TWL06.txt ] || curl -O http://norvig.com/ngrams/TWL06.txt\n", " \n", "WORDS = set(open('TWL06.txt').read().split())" ] }, { "cell_type": "code", "execution_count": 11, "metadata": {}, "outputs": [ { "data": { "text/plain": [ "178691" ] }, "execution_count": 11, "metadata": {}, "output_type": "execute_result" } ], "source": [ "len(WORDS)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "That's a lot of words; which one has the longest path?" ] }, { "cell_type": "code", "execution_count": 12, "metadata": {}, "outputs": [ { "data": { "text/plain": [ "'PALEOMAGNETISMS'" ] }, "execution_count": 12, "metadata": {}, "output_type": "execute_result" } ], "source": [ "max(WORDS, key=path_length)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "And the ten longest paths? Including the lengths? We'll use a helper function, `print_top`, which prints the top *n* items in a seqence according to some key function:" ] }, { "cell_type": "code", "execution_count": 13, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "72.2 PALEOMAGNETISMS\n", "70.0 ANTIQUARIANISMS\n", "69.9 ELECTROANALYSIS\n", "69.9 ANTIAPHRODISIAC\n", "69.3 PARLIAMENTARIAN\n", "68.9 BLEPHAROSPASMS\n", "68.6 BIDIALECTALISMS\n", "67.6 PALEOGEOGRAPHIC\n", "67.6 SPERMATOZOANS\n", "67.1 APOCALYPTICISMS\n" ] } ], "source": [ "def print_top(n, sequence, key=None, formatter='{:.1f} {}'.format):\n", " \"Find the top n elements of sequence as ranked by key function, and print them.\"\n", " for x in sorted(sequence, key=key, reverse=True)[:n]:\n", " print(formatter(key(x), x))\n", " \n", "print_top(10, WORDS, path_length)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Question 2: Highest Path Length to Word Length Ratio?\n", "===\n", "\n", "Very long words tend to have long path lengths. But what words have the highest *ratio*\n", "of path length to word length? (I decided to measure word length by number of letters; an alternative is number of segments.) " ] }, { "cell_type": "code", "execution_count": 14, "metadata": { "collapsed": true }, "outputs": [], "source": [ "def path_length_ratio(word, kbd=qwerty): return path_length(word, kbd) / len(word)" ] }, { "cell_type": "code", "execution_count": 15, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "6.9 PALAPA\n", "6.7 PAPAL\n", "6.4 PAPA\n", "6.4 JALAPS\n", "6.2 SLAPS\n", "6.2 KAMALA\n", "6.2 LAPELS\n", "6.2 PAPS\n", "6.2 HALALA\n", "6.1 SPALE\n" ] } ], "source": [ "print_top(10, WORDS, path_length_ratio)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Question 3: Average Segment Length on Work Load?\n", "===\n", "\n", "What is the average segment length for a typical typing work load? To answer that, we need to know what a typical work load is. We will read a file of \"typical\" text, and count how many times each segment is used. A `Workload` is a `dict` of the form `{segment: proportion, ...},` e.g. `{'AB': 0.02}`, where each key is a two-letter string (or *bigram*) representing a segment, and each value is the proportion of time that segment appears in the workload. Since the distance from `A` to `B` on a keyboard is the same as the distance from `B` to `A`, we can combine them together into one count;\n", "I'll arbitrarily choose count them both under the alphabetically first one. I make a `Counter` of all two-letter segments, and `normalize` it so that the counts sum to 1 (and are thus probabilities)." ] }, { "cell_type": "code", "execution_count": 16, "metadata": { "collapsed": true }, "outputs": [], "source": [ "def Workload(text):\n", " \"\"\"Create a Workload--a dict of the form {'AB': 1000, ...} \n", " saying how often each letter pair occurs in text.\"\"\"\n", " segments = (min(AB, AB[::-1]) for AB in bigrams(text))\n", " return normalize(Counter(segments))\n", "\n", "def bigrams(text): return re.findall(r'(?=([A-Z][A-Z]))', text)\n", "\n", "def normalize(dictionary):\n", " \"Normalize a {key: val} dict so that the sum of the vals is 1.0.\"\n", " total = sum(dictionary.values())\n", " for k in dictionary:\n", " dictionary[k] /= total\n", " return dictionary" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "*Note:* Some `re` trickiness here: `([A-Z][A-Z])` means a group of two consecutive letters. But if I only looked for that, then in the text `'FOUR'` I would find `['FO', 'UR']`. So I use the `?=` operator, which says to check for a match, but don't consume the matched characters. So I can find `['FO', 'OU', 'UR']`, which is what I want.\n", "\n", "Let's see what a workload looks like for a tiny text:" ] }, { "cell_type": "code", "execution_count": 17, "metadata": {}, "outputs": [ { "data": { "text/plain": [ "Counter({'AL': 0.05,\n", " 'AO': 0.05,\n", " 'DO': 0.05,\n", " 'GO': 0.1,\n", " 'HO': 0.05,\n", " 'HS': 0.05,\n", " 'IS': 0.05,\n", " 'OO': 0.55,\n", " 'OT': 0.05})" ] }, "execution_count": 17, "metadata": {}, "output_type": "execute_result" } ], "source": [ "Workload('SHOT IS GOOD -- GOOOOOOOOOOOAL!')" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "I happened to have a file of about a megabyte of random text, `smaller.txt`; that should work fine as a typical work load:" ] }, { "cell_type": "code", "execution_count": 18, "metadata": { "collapsed": true }, "outputs": [], "source": [ "! [ -e smaller.txt ] || curl -O http://norvig.com/ngrams/smaller.txt\n", " \n", "WORKLOAD = Workload(open('smaller.txt').read().upper())" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Let's peek at the most common segments:" ] }, { "cell_type": "code", "execution_count": 19, "metadata": {}, "outputs": [ { "data": { "text/plain": [ "[('HT', 0.04144819308354474),\n", " ('ER', 0.04050225926898767),\n", " ('EH', 0.03576926529702987),\n", " ('IN', 0.02699818128015268),\n", " ('AN', 0.02320447132440709),\n", " ('NO', 0.022344984888333263),\n", " ('EN', 0.021994208025641622),\n", " ('IT', 0.021467211506811055),\n", " ('ES', 0.020667573255841017),\n", " ('DE', 0.020619362217840744)]" ] }, "execution_count": 19, "metadata": {}, "output_type": "execute_result" } ], "source": [ "WORKLOAD.most_common(10)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "The most popular segments, at about 4% each are `HT/TH` and `ER/RE`. Now we can compute the workload average:" ] }, { "cell_type": "code", "execution_count": 20, "metadata": { "collapsed": true }, "outputs": [], "source": [ "def workload_average(kbd, workload=WORKLOAD):\n", " \"The average segment length over a workload of segments.\"\n", " return sum(distance(kbd[A], kbd[B]) * workload[A+B]\n", " for (A, B) in workload)" ] }, { "cell_type": "code", "execution_count": 21, "metadata": {}, "outputs": [ { "data": { "text/plain": [ "3.2333097802127653" ] }, "execution_count": 21, "metadata": {}, "output_type": "execute_result" } ], "source": [ "workload_average(qwerty)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "So, on average, your finger has to travel a little over 3 keys from one letter to the next over a typical workload." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Aside: Visualizing a Keyboard\n", "---\n", "\n", "We'll need a way of visualizing what a keyboard looks like. I could just `print` letters, but I think it is more compelling to use IPython's `matplotlib` module. In the function `show_kbd` we'll draw a square around the center point of each key, and annotate the square with the key letter. " ] }, { "cell_type": "code", "execution_count": 22, "metadata": { "collapsed": true }, "outputs": [], "source": [ "def show_kbd(kbd, name='keyboard'):\n", " \"Plot the keyboard and show title/stats.\"\n", " for L in kbd:\n", " plot_square(kbd[L].x, -kbd[L].y, label=L)\n", " plt.axis('equal'); plt.axis('off')\n", " plt.title(title(kbd, name));\n", " plt.show()\n", "\n", "def plot_square(x, y, label='', style='k:'):\n", " \"Plot a square with center (x, y) and optional label.\"\n", " H = 1/2\n", " plt.plot([x-H, x+H, x+H, x-H, x-H], \n", " [y-H, y-H, y+H, y+H, y-H], style) \n", " plt.annotate(label, (x-H/4, y-H/4)) # H/4 seems to place label well.\n", " \n", "def title(kbd, name): \n", " X, Y = span(kbd[L].x for L in kbd), span(kbd[L].y for L in kbd)\n", " return ('{}: size: {:.1f}×{:.1f}; path length: {:.1f}'\n", " .format(name, X, Y, workload_average(kbd)))\n", " \n", "def span(numbers):\n", " numbers = list(numbers)\n", " return max(numbers) - min(numbers)" ] }, { "cell_type": "code", "execution_count": 23, "metadata": {}, "outputs": [ { "data": { "image/png": "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\n", "text/plain": [ "