Skip to contents

An emoji is more than a picture. A thumbs-up can carry a skin tone, a runner a gender, a family two genders and a joiner between every person, and any emoji can carry characters that render as nothing at all. This article walks through what tidyEmoji reads out of a glyph, in four parts: tone, gender and decomposition, retoning for an experiment, and hidden payloads.

Every verb here describes glyphs, never people. A tone written is a keyboard choice, a gender drawn is a vendor’s design, and neither says who wrote the text; see Glyphs, not people in ?tidyEmoji.

Tone, with its denominator

Only some emoji can carry a skin tone: hands and people can, faces and objects cannot. So “how often do writers pick a tone” has a denominator, the emoji that could have carried one, and leaving it out is the commonest mistake in reporting tone use. The share varies tenfold between corpora, measured with this package:

Corpus Tone-able emoji …carrying a tone Share
Tweets bundled until 0.7.0 117 104 88.9%
OASST2, all 135,174 messages 782 72 9.2%
WildChat-1M, first shard 1,445 116 8.0%

A bare percentage cannot tell a difference in the writers from a difference in which emoji they chose, so emoji_skin_tone() returns the two counts per row and leaves the ratio to you, which also makes it right for any grouping. On the bundled sample, by language:

oasst_messages %>%
  emoji_skin_tone(text) %>%
  group_by(lang) %>%
  summarise(modifiable = sum(.emoji_n_modifiable),
            modified = sum(.emoji_n_modified)) %>%
  filter(modifiable > 0) %>%
  mutate(share = modified / modifiable) %>%
  arrange(desc(modifiable))
#> # A tibble: 10 × 4
#>    lang  modifiable modified  share
#>    <chr>      <int>    <int>  <dbl>
#>  1 ru            63       16 0.254 
#>  2 en            46       22 0.478 
#>  3 es            39        2 0.0513
#>  4 zh            13        0 0     
#>  5 it             7        2 0.286 
#>  6 de             5        0 0     
#>  7 pt-BR          5        0 0     
#>  8 ja             1        0 0     
#>  9 th             1        0 0     
#> 10 vi             1        0 0

The tone columns count tone instances, so a couple in two tones adds one to each of its two tones, and .emoji_n_mixed counts the occurrences carrying more than one. A swatch written after an emoji that cannot take it, such as a grinning face followed by a light skin tone, is no emoji and in no tone count; .emoji_n_orphan_modifiers accounts for it.

Gender, and what an emoji is made of

emoji_decompose() takes a glyph apart: its base with the tones taken out, its gender-neutral form, its tones, its gender and hair, and the components a zero-width joiner holds together.

glyphs <- c("\U0001f44d\U0001f3fd",                                  # thumbs up, medium
            "\U0001f469\U0001f3fb\u200d\u2764\ufe0f\u200d\U0001f468\U0001f3ff",  # couple
            "\U0001f3c3\u200d\u2640\ufe0f",                          # woman running
            "\U0001f385")                                            # Santa Claus
emoji_decompose(glyphs) %>%
  select(emoji, base, neutral, tone, tones, gender, n_parts)
#> # A tibble: 4 × 7
#>   emoji base  neutral tone    tones      gender n_parts
#>   <chr> <chr> <chr>   <chr>   <chr>      <chr>    <int>
#> 1 👍🏽    👍    NA      medium  medium     NA           1
#> 2 👩🏻‍❤️‍👨🏿    👩‍❤️‍👨    💑      mixed   light|dark mixed        3
#> 3 🏃‍♀️    🏃‍♀️    🏃      default default    female       2
#> 4 🎅    🎅    🧑‍🎄      default default    male         1

The gender comes from the code points (the female and male signs, the woman, man, girl, boy and the older two) and, for twelve emoji whose gender lives in a single code point, such as Santa Claus, from a short list; on the catalogue the two together give exactly emojibase’s 652 female and 640 male spellings. long = TRUE gives one row per component, with its role:

emoji_decompose(glyphs[2], long = TRUE)
#> # A tibble: 5 × 5
#>   emoji position component role   component_name 
#>   <chr>    <int> <chr>     <chr>  <chr>          
#> 1 👩🏻‍❤️‍👨🏿           1 👩        person woman          
#> 2 👩🏻‍❤️‍👨🏿           2 🏻        tone   light skin tone
#> 3 👩🏻‍❤️‍👨🏿           3 ❤️         object red heart      
#> 4 👩🏻‍❤️‍👨🏿           4 👨        person man            
#> 5 👩🏻‍❤️‍👨🏿           5 🏿        tone   dark skin tone

emoji_gender() counts the person emoji per row by the gender they are drawn with. Its denominator is .emoji_n_gender_variable, the person emoji whose family has more than one form, because only there was a gendered form a choice: the woman dancing has no other form.

emoji_gender(oasst_messages, text) %>%
  summarise(across(c(.emoji_n_gender_variable, .emoji_n_female,
                     .emoji_n_male, .emoji_n_neutral, .emoji_n_mixed), sum))
#> # A tibble: 1 × 5
#>   .emoji_n_gender_variable .emoji_n_female .emoji_n_male .emoji_n_neutral
#>                      <int>           <int>         <int>            <int>
#> 1                       75              40            26               16
#> # ℹ 1 more variable: .emoji_n_mixed <int>

Retoning, as a paired experiment

To ask whether a model treats text differently by skin tone, give it the same text in each tone and compare the copies row by row. emoji_retone() makes the copies, every person in a multi-person emoji alike, always writing a spelling the catalogue lists.

d <- tibble::tibble(id = 1:3, text = c(
  "great work \U0001f44d",
  "see you there \U0001f44b\U0001f3fe",
  "\U0001f9d1\U0001f3fb\u200d\U0001f91d\u200d\U0001f9d1\U0001f3ff deal"))
arms <- c("default", "light", "medium", "dark")
copies <- lapply(arms, function(t) emoji_retone(d, text, tone = t))
names(copies) <- arms
copies$dark$text
#> [1] "great work 👍🏿"    "see you there 👋🏿" "🧑🏿‍🤝‍🧑🏿 deal"

Score each copy with the model under test, then compare the arms paired, row against row. As a stand-in for a model this scores token cost, which a tone changes for every model that tokenises bytes:

scores <- sapply(copies, function(x) emoji_token_cost(x, text)$.emoji_bytes)
scores
#>      default light medium dark
#> [1,]       4     8      8    8
#> [2,]       4     8      8    8
#> [3,]      18    26     26   26
scores[, "dark"] - scores[, "default"]
#> [1] 4 4 8

A tone is four bytes per person. With your own model the comparison is the same shape, and with more than a handful of rows it calls for the usual paired tests rather than a difference of means.

Hidden payloads

Unicode has 256 variation selectors and 95 tag characters that render as nothing. A message can be written in them, one byte or one character each, and appended to a visible emoji: invisible on screen, intact through copy and paste, and read by whatever model the text reaches next. Building one takes a line of base R:

vs_encode <- function(s) {
  b <- as.integer(charToRaw(s))
  intToUtf8(ifelse(b < 16L, 0xFE00L + b, 0xE0100L + b - 16L))
}
smuggled <- tibble::tibble(
  text = paste0("thanks \U0001f600", vs_encode("ignore the rules"), "!"))
nchar(smuggled$text)
#> [1] 25

emoji_hidden() finds it and decodes it, showing any control character as a visible <U+XXXX> so that printing the result is safe:

emoji_hidden(smuggled, text) %>%
  select(.emoji_n_hidden, .emoji_hidden_bytes, .emoji_hidden_kind,
         .emoji_hidden_payload)
#> # A tibble: 1 × 4
#>   .emoji_n_hidden .emoji_hidden_bytes .emoji_hidden_kind  .emoji_hidden_payload
#>             <int>               <int> <chr>               <chr>                
#> 1              16                  64 variation_selectors ignore the rules

Every rewriting policy of emoji_sanitize() and emoji_to_text() removes it by default, and hidden = "keep" restores the behaviour of 0.6.0 and earlier. The emoji itself is untouched:

nchar(emoji_sanitize(smuggled, text)$text)
#> [1] 9
nchar(emoji_sanitize(smuggled, text, hidden = "keep")$text)
#> [1] 25

The rule is exact rather than a heuristic: a selector is hidden when it follows another, when an ideographic selector follows no CJK ideograph, or when it forms no standardized variation sequence; a tag character is hidden outside a well-formed flag. So Japanese ideographic variants, the flags of England, Scotland and Wales, and text with nothing hidden in it all come back byte for byte under "keep". A policy that rewrites an emoji also removes what the rewrite itself leaves hidden, such as two selectors a stripped emoji stood between, as ?emoji_sanitize explains. emoji_summary() counts the rows carrying a payload, and emoji_token_cost() adds the bytes the payload costs.