DEV Community

Cover image for NLP #2: Unicode and UTF-8
GradientGym
GradientGym

Posted on Originally published at almoghagbi.com

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=976 \cdot 16 + 1 = 97 .

Four luggage tags, each with one character and its number: a is U+0061, é is U+00E9, ₪ is U+20AA, and a smiley emoji is U+1F600.

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.

Two rows of boxes. A long pale row, labelled "4 bytes each: 84 bytes", sits above a much shorter blue row, labelled "UTF-8: 27 bytes".

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.

Four linked boxes with paper labels: "three of us", "with the one before", "with the one before", "just me". A bracket joins the first three into ₪; the fourth alone is 5.

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.

The bytes C3 A9 take two paths: read as UTF-8 they give é, with a check mark; read as an old encoding they give é, with a warning sign.

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 cc , with 0≤c≤0x10FFFF0 \le c \le \texttt{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,…,cnc_1, c_2, \dots, c_n , where nn is its
length in characters.

There are 0x10FFFF+1=1,114,112\texttt{0x10FFFF} + 1 = 1{,}114{,}112 possible code points.
Since 220=1,048,5762^{20} = 1{,}048{,}576 is too few and 221=2,097,1522^{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 nn
characters takes 4n4n bytes. UTF-8 is variable-length.

Key equation (UTF-8 length). UTF-8 stores code point cc in

ℓ(c)={1c<0x80 20x80≤c<0x800 30x800≤c<0x10000 40x10000≤c≤0x10FFFF \ell(c) = \begin{cases} 1 & c < \texttt{0x80} \ 2 & \texttt{0x80} \le c < \texttt{0x800} \ 3 & \texttt{0x800} \le c < \texttt{0x10000} \ 4 & \texttt{0x10000} \le c \le \texttt{0x10FFFF} \end{cases}

bytes, so a text takes ∑i=1nℓ(ci)\sum_{i=1}^{n} \ell(c_i) bytes. Take the text
Great café, only ₪5 😀. It has 18 ASCII characters, plus é (2 bytes), ₪ (3) and 😀 (4):
18+2+3+4=2718 + 2 + 3 + 4 = 27 bytes, against 4⋅21=844 \cdot 21 = 84 in UTF-32.

A ruler of code points, not to scale, with marks at 0, 0x80 (128), 0x800 (2,048), 0x10000 (65,536) and 0x10FFFF (1,114,111). The four stretches between the marks take 1, 2, 3 and 4 bytes.

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 27−1=1272^7 - 1 = 127 , so 1 byte stops at 0x80 = 128. With 11 slots,
2 bytes stop at 2112^{11} = 0x800. With 16 slots, 3 bytes stop at 2162^{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. \texttt{110}\,\texttt{00011} = \texttt{0xC3}, \qquad \texttt{10}\,\texttt{101001} = \texttt{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).

The bits of é (U+00E9), 00011 101001, split in two groups. Marker 110 plus 00011 makes C3; marker 10 plus 101001 makes A9.

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
  1. The first byte starts with 1110, so this character takes 3 bytes. The next two start with 10, as they should.
  2. Remove the marker bits and join the rest: 0010 000010 101010. That's 0x20AA, the code point of ₪.
  3. The next byte starts with 0, so it's a character by itself: 0x35, the digit 5.

Four bytes in binary with their marker bits highlighted. The first three join into 0010 000010 101010, which is ₪; the fourth alone is 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.

A torn strip of four bytes: 9F, 98, 80, 47. The first three are continuation bytes tagged "skip"; decoding starts at 47.

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
Enter fullscreen mode Exit fullscreen mode

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 220=1,048,576 different values, too few. With 21 bits there are 221=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:

11010111=0xD7,10101001=0xA9.

Check it in the widget, or with "ש".encode("utf-8").

  • A is wrong: those are the code point's digits with no marker bits at all.
  • B is wrong: it uses the 3-byte layout, but this code point fits in 2 bytes. UTF-8 always uses the shortest form.
  • C is wrong: E9 = 11101001 starts with 1110, so it can't be a continuation byte.

9. (Math · UTF-8 bit layout) Which character do the bytes E2 82 AC encode?

  • A. ₪

  • B. €

  • C. é

  • D. Three separate characters

Answer

B. E2 = 1110 0010 starts with 1110, so this is a 3-byte character, and its chunk is 0010. 82 = 10 000010 gives 000010, and AC = 10 101100 gives 101100. Together: 0010 000010 101100 = 0x20AC, the euro sign.

  • A is wrong: ₪ is U+20AA, which is E2 82 AA. It differs only in the last byte.
  • C is wrong: é is C3 A9, only 2 bytes.
  • D is wrong: 82 and AC start with 10, so they continue the character that E2 started.

10. (Math · self-synchronizing) A download breaks in the middle of our review, and the part you got starts with the bytes 82 AA 35 20 .... Where is the first whole character?

  • A. 82

  • B. AA

  • C. 35, the digit 5

  • D. 20, the space

Answer

C. 82 = 10000010 and AA = 10101010 both start with 10, so they're continuation bytes: the end of the ₪ (E2 82 AA), whose first byte was lost. Skip them. 35 starts with 0, so it's a whole 1-byte character, and decoding starts there.

  • A and B are wrong: a byte starting with 10 is never the start of a character.
  • D is wrong: 20 is a whole character too, but it's the second one. 35 comes first.

Next: byte-pair encoding (BPE), the algorithm that builds a tokenizer's
vocabulary from these bytes (section 2.4).

Co-authored with AI.

Top comments (0)