NLP #2: Unicode and UTF-8
DEV Community

NLP #2: Unicode and UTF-8

Originally published on almoghagbi.com, where the figures are interactive.

Before a natural language processing (NLP) model can split text into words or tokens, the text has to exist inside the computer, where there are only numbers. This post covers section 2.3 of Speech and Language Processing: how a character becomes a number (Unicode), and how that number becomes bytes in a file (UTF-8).

The next post builds on it: modern tokenizers start from exactly these bytes.

Intuition

Every character gets a number. Our running example for this post is a short review: Great café, only ₪5 😀

In the 1960s, computers in the US stored English text with a code called ASCII. It gave each character a number below 128: a is 97, G is 71, a space is 32. Each character took one byte.

A byte is 8 bits, and each bit is a 0 or a 1. That gives 256 possible values, from 0 to 255.

ASCII has no é, no ₪, no Hebrew, no Chinese and no emoji. Three of the characters in our review don't exist in it.

Unicode fixes that with one big list of every character in every writing system: Latin, Hebrew, Arabic, Devanagari, Chinese and ancient scripts like cuneiform. It also covers math symbols and emoji. Version 16.0 has more than 150,000 characters from 168 scripts.

Each one gets its own number, called a code point. Code points are written in hexadecimal, which counts in base 16 instead of base 10. It uses the digits 0-9, then A-F for 10 to 15. We write U+ in front: é is U+00E9, ₪ is U+20AA and 😀 is U+1F600.

The first 128 code points are exactly ASCII, so a is still 97. In hexadecimal that's U+0061, because 61 means 6 sixteens plus 1: 6 ⋅ 16 + 1 = 97.

A code point names a character. It doesn't say how the character looks. That's the font's job: a in bold, in italics or in a typewriter font is the same code point. The drawn shape is called a glyph.

Short codes for common characters

Now we need to write those numbers into a file. The simplest way is to give every character 4 bytes, enough for the largest code point. Our review has 21 characters, so it would take 84 bytes, most of them zeros. That wastes space. Worse, old software reads a zero byte as "end of text".

An encoding is a rule for turning code points into bytes. UTF-8, the encoding used by nearly all of the web, uses short codes for common characters and longer ones for rare characters:

  • 1 byte for ASCII: English letters, digits, punctuation.
  • 2 bytes for accented Latin letters like é, and for Greek, Cyrillic, Hebrew and Arabic letters.
  • 3 bytes for most Chinese, Japanese and Korean characters, and symbols like ₪.
  • 4 bytes for emoji and rare characters.

Our review takes 27 bytes instead of 84. Since ASCII characters keep their single byte, every old ASCII file is already valid UTF-8.

Try it with your own text: ▶ Interactive figure: try it on the original post.

How a program knows where a character ends

A file is one long row of bytes, with no gaps between characters and no lengths stored next to them. So how does a program know that é takes two bytes while the 5 takes one?

Every byte starts with a small label. On a character's first byte, the label says how long the character is: "just me", "two of us", "three of us" or "four of us". On every later byte, it says "I belong to the one before me". The program reads a label, takes that many bytes, then does the same for the next character.

₪5 is saved as four bytes. The first byte says "three of us", so that byte and the next two make ₪. The fourth says "just me", so it's the 5. The math section shows the exact bits.

Characters and bytes are different counts

So a text has two lengths: how many characters it has, and how many bytes it takes. For English text they are equal. For Hebrew they're not: "שלום" has 4 characters and takes 8 bytes.

Every text file is stored in some encoding. Read it with the wrong one and you get garbage: a UTF-8 é read as an older Western-European encoding shows up as é. If you've seen "café" on a website, that's what happened.

For NLP, bytes are a handy unit. A model whose units are characters needs room for 150,000 of them, and it still meets characters it has never seen. A model whose units are bytes needs only 256, and every possible text is made of them. That's why many tokenizers start from UTF-8 bytes, as the next post shows.

The math

Code points

Definition 1 (Code point). Unicode assigns each character a unique integer, its code point c, with 0 ≤ c ≤ 0x10FFFF (0x marks a hexadecimal number). A code point is written U+ followed by at least four hex digits.

A text is then a sequence of code points c1, c2, …, cn, where n is its length in characters.

There are 0x10FFFF + 1 = 1,114,112 possible code points. Since 2^20 = 1,048,576 is too few and 2^21 = 2,097,152 is enough, a code point needs 21 bits.

Encodings

Definition 2 (Encoding). An encoding turns a sequence of code points into a sequence of bytes (integers from 0 to 255), in a way that can be reversed. Turning the bytes back into code points is decoding.

A fixed-length encoding uses the same number of bytes for every code point. A variable-length encoding doesn't.

ASCII is the old one-byte code for the first 128 code points. UTF-32 is a fixed-length encoding: 4 bytes per code point, so a text of n characters takes 4n bytes. UTF-8 is variable-length.

Key equation (UTF-8 length). UTF-8 stores code point c in ℓ(c) = { 1 c < 0x80 2 0x80 ≤ c < 0x800 3 0x800 ≤ c < 0x10000 4 0x10000 ≤ c ≤ 0x10FFFF bytes, so a text takes ∑ i = 1 n ℓ(ci) bytes.

Take the text Great café, only ₪5 😀. It has 18 ASCII characters, plus é (2 bytes), ₪ (3) and 😀 (4): 18 + 2 + 3 + 4 = 27 bytes, against 4 ⋅ 21 = 84 in UTF-32.

How UTF-8 packs the bits

Key equation (UTF-8 bit layout). The code point's bits are cut into chunks. Each byte starts with a few marker bits that say what it is, followed by one chunk:

Bytes Free slots (x) Byte 1 Bytes 2-4 1 7 0xxxxxxx none 2 5 + 6 = 11 110xxxxx 10xxxxxx 3 4 + 6 + 6 = 16 1110xxxx 10xxxxxx ×2 4 3 + 6 + 6 + 6 = 21 11110xxx 10xxxxxx ×3

Each x is an empty slot. You fill the slots with the bits of the code point, left to right. The 0s and 1s written in the table are the marker bits. They never change.

In a character of 2 or more bytes, the number of 1s at the start of the first byte equals the number of bytes. Every byte after the first, called a continuation byte, starts with 10.

The slots also explain the limits in the length equation. With 7 slots, the largest number is 2^7 − 1 = 127, so 1 byte stops at 0x80 = 128. With 11 slots, 2 bytes stop at 2^11 = 0x800. With 16 slots, 3 bytes stop at 2^16 = 0x10000.

The simplest case is a, code point 97, which is 1100001 in binary. Those 7 bits fill the 7 slots of 0xxxxxxx, giving 01100001 = 0x61. That's one byte, the same as in ASCII.

Now take é, code point U+00E9 = 233. It's between 0x80 and 0x7FF, so it takes 2 bytes with 11 slots. In binary, 233 is 11101001, which is only 8 bits, so we add zeros on the left to fill the 11 slots: 00011 101001, with a space only to show the two groups. The first 5 bits go after 110, the last 6 after 10: 110 00011 = 0xC3, 10 101001 = 0xA9. So é is stored as C3 A9.

Click é in the widget above to see the same packing, and then try ₪ (3 bytes) and 😀 (4 bytes).

Reading a file back

A file holds only bytes, with no separators or lengths. Here is our whole review as saved on disk, all 27 bytes in hexadecimal: 47 72 65 61 74 20 63 61 66 C3 A9 2C 20 6F 6E 6C 79 20 E2 82 AA 35 20 F0 9F 98 80

A decoder reads the marker bits of a first byte, takes that many bytes, then repeats. Here is the ₪5 part, E2 82 AA 35, written in bits: 11100010 10000010 10101010 00110101

The first byte starts with 1110, so this character takes 3 bytes. The next two start with 10, as they should. Remove the marker bits and join the rest: 0010 000010 101010. That's 0x20AA, the code point of ₪. The next byte starts with 0, so it's a character by itself: 0x35, the digit 5.

Useful properties

The layout has useful properties:

  • ASCII-compatible: a byte below 128 is always a whole ASCII character.
  • No zero bytes, except for the code point U+0000 itself.
  • Self-synchronizing: a byte starting with 10 is never the start of a character. Jump into the middle of a file, skip at most 3 such bytes, and you're at the start of the next character.

In Python, a string is a sequence of code points, and encode gives the bytes:

text = " Great café, only ₪5 😀 "
print ( len ( text )) # 21 characters (code points)
data = text . encode ( " utf-8 " )
print ( len ( data )) # 27 bytes
print ( hex ( ord ( " é " )), data [ 9 : 11 ]. hex ( " " )) # 0xe9 c3 a9
print ( len ( text . encode ( " utf-32-be " ))) # 84 bytes

Exercises

Each question has one correct answer. Pick your answer, then read the explanation. Questions 6-10 use the definitions and equations above.

  1. (Intuition) ASCII used one byte per character and only numbers below 128. Why was that enough for English, but not for Hebrew?

    A. Hebrew letters need more than one byte because they're written right to left.
    B. Hebrew has more letters than English.
    C. ASCII could store only capital letters.
    D. English fits below 128, but ASCII has no Hebrew letters.

    Answer D. Upper- and lowercase letters, digits, punctuation and a space come to about 95 printable characters, so they all fit below 128. ASCII has no Hebrew letters at all, so a Hebrew text can't be written in it. A is wrong: writing direction has nothing to do with how many bytes a character takes. B is wrong: Hebrew has 22 letters and English has 26. The number of letters wasn't the problem. C is wrong: ASCII has both cases. a is 97 and A is 65.

  2. (Intuition) Are a, a (bold) and a (italic) the same code point?

    A. No, each style has its own code point.
    B. Yes, bold and italic are different glyphs of the same code point.
    C. Only a and a are the same; italic letters have their own code points.
    D. It depends on the file's encoding.

    Answer B. All three are U+0061. Bold and italic are different glyphs, chosen by the font. A code point names the character, not its look. A and C are wrong: style doesn't change the code point. a and A do differ (U+0061 and U+0041), because they're different characters. D is wrong: the encoding decides how a code point becomes bytes. It doesn't decide which code point a character has.

  3. (Intuition) A friend's name, Zoë, shows up in a spreadsheet as "Zoë". What went wrong?

    A. The file was damaged during the download.
    B. The spreadsheet's font doesn't include ë.
    C. The file is UTF-8, but the spreadsheet read it as an older encoding.
    D. The file was saved in UTF-32.

    Answer C. In UTF-8, ë takes two bytes. An older encoding with one character per byte turns each of those bytes into its own character: Ã and «. Opening the file as UTF-8 fixes it. A is wrong: the bytes are intact, which is why reading them correctly fixes the name. B is wrong: a missing glyph shows up as an empty box or a placeholder, not as two other letters. D is wrong: in UTF-32 every character takes 4 bytes, most of them zeros, so "Z", "o" and all the rest would look broken too.

  4. (Intuition) Compare "hello" and "שלום" in UTF-8. Which statement is true?

    A. "hello" has more characters, but "שלום" takes more bytes.
    B. "hello" has more characters and takes more bytes.
    C. "שלום" has more characters and takes more bytes.
    D. They take the same number of bytes.

    Answer A. "hello" has 5 characters against 4. Each English letter takes 1 byte, so "hello" takes 5 bytes. Each Hebrew letter takes 2, so "שלום" takes 8. More characters doesn't always mean more bytes, which is why a text has two different lengths. B and D are wrong: they ignore that Hebrew letters take 2 bytes each. C is wrong: "שלום" has only 4 characters.

  5. (Intuition, widget) In the widget, replace the text with a thumbs-up with a skin tone: 👍🏽. How many characters does it show?

    A. 1
    B. 2
    C. 4
    D. 8

    Answer B. The widget shows 👍 (U+1F44D) and a skin-tone modifier (U+1F3FD). What you see as one symbol can be several code points, so counting "characters" is a choice, like counting words in post 1. A is what you see on screen, not the number of code points. C is the bytes of one of the two code points. D is the total number of bytes: 4 for each code point.

  6. (Math · Definition 1) Why does a code point need 21 bits, and not 20?

    A. 20 bits give too few values for all the code points.
    B. The extra bit marks where the code point ends.
    C. Each byte in UTF-8 reserves one bit for a marker.
    D. Unicode has 21 groups of characters.

    Answer A. The code points run from 0 to 0x10FFFF, which makes 1,114,112 of them. With 20 bits there are only 2^20 = 1,048,576 different values, too few. With 21 bits there are 2^21 = 2,097,152, enough. B and C are wrong: these 21 bits count only the code point itself. Marker bits are added by UTF-8 on top of them, and they depend on the encoding. D is wrong: the number of bits comes from the size of the range, not from how characters are grouped.

  7. (Math · UTF-8 length) How many bytes does "naïve ₪100 🎉" take in UTF-8, and in UTF-32?

    A. 12 in UTF-8, 48 in UTF-32
    B. 15 in UTF-8, 48 in UTF-32
    C. 18 in UTF-8, 48 in UTF-32
    D. 18 in UTF-8, 72 in UTF-32

    Answer C. There are 12 characters: n, a, ï, v, e, space, ₪, 1, 0, 0, space, 🎉. In UTF-8, the 9 ASCII characters take 1 byte each, ï takes 2, ₪ takes 3 and 🎉 takes 4, so 9 + 2 + 3 + 4 = 18 bytes. In UTF-32, every character takes 4 bytes: 4 ⋅ 12 = 48. A is wrong: 12 is the number of characters, not bytes. B is wrong: it counts ï and ₪ as 1 byte each, so 9 + 1 + 1 + 4 = 15. D is wrong: UTF-32 multiplies the characters by 4, not the UTF-8 bytes.

  8. (Math · UTF-8 bit layout) How is the Hebrew letter ש (U+05E9) encoded in UTF-8?

    A. 05 E9
    B. E0 97 A9
    C. C5 E9
    D. D7 A9

    Answer D. 0x05E9 is between 0x80 and 0x7FF, so it takes 2 bytes with 11 slots. In binary, 0x05E9 = 101 1110 1001, so the 11 bits are 10111 101001. Then: 110 10111 = 0xD7, 10 101001 = 0xA9. Check it in th

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.