--- title: "Reversible emoji preprocessing for language models" author: 'Youzhi Yu
University of Chicago' output: rmarkdown::html_vignette: toc: true toc_depth: 3 vignette: > %\VignetteIndexEntry{Reversible emoji preprocessing for language models} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r, include = FALSE} knitr::opts_chunk$set( collapse = TRUE, message = FALSE, warning = FALSE, comment = "#>" ) ``` ## The decision nobody writes down Every pipeline that sends user text to a language model makes a choice about emoji, and most make it by accident: a regex in a cleaning script drops anything non-ASCII, and the decision never appears in the methods section. It is a consequential choice. An emoji is two or more tokens rather than one character (estimated below: 2 for a plain smiley, 13 for a family), models disambiguate them poorly, and the glyphs readers disagree about are not the obvious ones. In the bundled Emoji Sentiment Ranking, annotator disagreement runs *against* the strength of the sentiment: `emoji_ambiguity()`'s entropy correlates -0.31 with the absolute sentiment score, and averages 0.53 over the 374 glyphs scoring `abs(sentiment_score) >= 0.5` against 0.76 over the other 595. So a sentiment score alone does not tell you which emoji are risky to strip. Worse, most ways of removing emoji are one-way. If the pipeline has to hand text back to a human, show a highlighted excerpt, or reconstruct what was sent, a stripped emoji is gone. `emoji_sanitize()` turns that accident into one named argument, and this article is about the property the argument's values do *not* share: **reversibility**. Only one policy both removes the emoji and survives a round trip. ```{r setup} library(tidyEmoji) library(dplyr) ``` Everything here runs on the package's declared dependencies. There are no optional packages and no network calls. ## A corpus built to break things A round trip that works on a smiley proves very little. The cases that matter are the multi-code-point ones: an emoji with a skin-tone modifier, a flag built from two regional indicators, a family built from four people and three joiners, a keycap built from a digit plus a presentation selector plus a combining enclosing mark, and a glyph whose canonical spelling carries an invisible presentation selector. ```{r} awkward <- tibble::tibble( case = c("plain", "skin tone", "flag", "ZWJ family", "keycap", "bare heart", "no emoji"), text = c( "ship it \U0001f600 today", "nice work \U0001f44d\U0001f3fd", "landed in \U0001f1fa\U0001f1f8 already", paste("the whole", intToUtf8(c(0x1F468, 0x200D, 0x1F469, 0x200D, 0x1F467, 0x200D, 0x1F466)), "came"), paste("step", intToUtf8(c(0x31, 0xFE0F, 0x20E3)), "first"), paste("love it", intToUtf8(0x2764)), "nothing to see here" ) ) awkward %>% emoji_token_cost(text) %>% select(case, .emoji_n, .emoji_codepoints, .emoji_bytes, .emoji_token_estimate) ``` Note the third column. One visible emoji is not one code point, which is the whole reason a hand-rolled substitution gets this wrong: the family is seven code points and the keycap is three. The last column is why a prompt budget notices: a plain smiley is estimated at two tokens and the family at thirteen. ## The ladder, run rather than described The five policies form a ladder of information loss. `?emoji_sanitize` tabulates it; here it is executed on one row, so you can see what a model would actually receive. ```{r} one <- awkward %>% filter(case == "ZWJ family") for (p in c("keep", "shortcode", "name", "placeholder", "strip")) { cat(format(p, width = 12), "|", emoji_sanitize(one, text, policy = p)$text, "\n") } ``` Now put them back. `text_to_emoji()` is the inverse of the shortcode form, and the question is which policies it can recover from. ```{r} restores <- function(policy, data = one) { sent <- emoji_sanitize(data, text, policy = policy) back <- text_to_emoji(sent, text)$text identical(back, data$text) } vapply(c("keep", "shortcode", "name", "placeholder", "strip"), restores, logical(1)) ``` Two policies survive, and only one of them is a real preprocessing step: `"keep"` changes nothing, so the interesting answer is `"shortcode"`. It is the only policy that both removes the emoji from the model's input and lets you reconstruct the user's text afterwards. ### It holds on the awkward cases ```{r} awkward %>% mutate( sanitized = emoji_sanitize(., text, policy = "shortcode")$text, restored = text_to_emoji(tibble::tibble(text = sanitized), text)$text, exact = restored == text ) %>% select(case, sanitized, exact) ``` All seven come back byte for byte, but the last two rows are exact for a reason worth separating out: nothing was substituted in them at all. The `"no emoji"` row is obvious. The `"bare heart"` row is the interesting one, because it looks like an emoji and is not detected as one. ```{r} hearts <- tibble::tibble( spelling = c("bare U+2764", "qualified U+2764 U+FE0F"), text = c(paste("love it", intToUtf8(0x2764)), paste("love it", intToUtf8(c(0x2764, 0xFE0F)))) ) hearts %>% emoji_summary(text) ``` One of the two is counted. Detection asks for emoji presentation, and a bare `U+2764` carries text presentation, so it passes through `emoji_sanitize()` untouched and survives the round trip by never being touched. The fully qualified spelling, which is what a keyboard emits, is detected and restores byte for byte: ```{r} sanitized_hearts <- emoji_sanitize(hearts, text, policy = "shortcode") sanitized_hearts$text text_to_emoji(sanitized_hearts, text)$text == hearts$text ``` This is a detection limitation rather than a reversibility one, and it is described under *Detection* in `?tidyEmoji`. It matters here because text that had its presentation selectors stripped upstream will quietly keep its emoji through a policy that was supposed to remove them. ## `wrap` is part of the contract, not a style choice `emoji_sanitize()` writes the shortcode as `:name:`, and `text_to_emoji()` looks for exactly that. Decorate the token and the emoji still comes back, but the decoration stays behind. Remove the colons and nothing comes back at all, silently, because the shortcode is now an ordinary word. ```{r} show_wrap <- function(w) { sent <- emoji_sanitize(one, text, policy = "shortcode", wrap = w) back <- text_to_emoji(sent, text)$text cat(format(w, width = 9), "|", if (identical(back, one$text)) "restored " else "NOT restored", "|", back, "\n") } invisible(lapply(c(":{x}:", "[:{x}:]", "<{x}>", "::{x}::"), show_wrap)) ``` The restored text in the last column separates the two failures. With a decorating wrap the emoji does come back and the decoration is left stranded around it. With `<{x}>` nothing comes back at all: the shortcode stays in the text as an ordinary word. That third row is the one to watch for in a code review, because nothing errors, the text looks cleaner, and the emoji are unrecoverable. ## Do not throw away the signal you just removed Sanitising for the model does not mean losing the emoji information. The point of doing this in tidyEmoji rather than with a regex is that the same pipeline can carry the emoji-derived features forward as ordinary columns, while the text column that goes to the model holds no emoji at all. ```{r} corpus <- tibble::as_tibble( utils::read.csv( system.file("extdata", "ata_tweets.csv", package = "tidyEmoji"), encoding = "UTF-8", stringsAsFactors = FALSE ) ) prepared <- corpus %>% emoji_sentiment(full_text) %>% # how positive were the emoji emoji_risk(full_text) %>% # how much annotators disagreed emoji_token_cost(full_text) %>% # what they cost in the prompt mutate(prompt_text = emoji_sanitize(., full_text, policy = "shortcode")$full_text) prepared %>% filter(.emoji_n > 0) %>% select(.emoji_n, .emoji_sentiment, .emoji_ambiguity_mean, .emoji_token_estimate, prompt_text) %>% head(3) ``` `prompt_text` is what you send. The four columns beside it are what you would have destroyed, and they are the ones a model reads poorly anyway: a disagreement score computed from how the lexicon's human annotators actually split over each glyph is not something a prompt recovers. ## What it saves, and how to measure it honestly ```{r} before <- corpus %>% emoji_token_cost(full_text) after <- prepared %>% select(full_text = prompt_text) %>% emoji_token_cost(full_text) tibble::tibble( stage = c("original", "shortcode"), emoji = c(sum(before$.emoji_n), sum(after$.emoji_n)), emoji_bytes = c(sum(before$.emoji_bytes), sum(after$.emoji_bytes)), est_tokens = c(sum(before$.emoji_token_estimate), sum(after$.emoji_token_estimate)) ) ``` The sanitised column holds no emoji at all, so its emoji cost is zero. That is not quite guaranteed: `"shortcode"` leaves a glyph it cannot name in place, so a corpus carrying a ZWJ sequence too new for the installed catalogue would still show a cost here. This one does not, which is worth checking rather than assuming. `"strip"` is the policy that clears the column whatever it is handed. (The bare heart above is a different case: it is never detected, so it costs nothing here and passes through every policy untouched.) Treat the token figures as what they are called: `.emoji_token_estimate` is an estimate. Pass your real tokeniser through `emoji_token_cost(tokenizer = )` when the number goes in a budget, and read `.emoji_bytes` and `.emoji_codepoints` when you want a fact rather than a model of one. ## Which policy, by intent | If the pipeline needs to | Use | Why | |---|---|---| | reconstruct the user's text afterwards | `"shortcode"` | the only reversible removal | | feed a screen reader or a human reading a log | `"name"` | reads as words, not as a token | | record that an emoji was present, not which | `"placeholder"` | keeps position, drops identity | | discard emoji entirely and never look back | `"strip"` | leaves no trace, and no way back | | leave the text alone | `"keep"` | the explicit no-op, visible in a diff | The middle three are not lesser versions of `"shortcode"`. They answer different questions, and each is the right answer to one of them. What matters is that the choice is now written down in the script rather than implied by a regex. ## Put the versions in the methods section Which glyphs exist, what they are called, and which shortcode maps to which emoji all depend on versions. A round trip is reproducible only against the catalogue that performed it. ```{r} emoji_provenance() %>% glimpse() ``` ## See also * `vignette("introduction", package = "tidyEmoji")` for the rest of the package: counting, categorising, sentiment and emotion scoring, co-occurrence, time series and model features. * `?emoji_sanitize` for the reversibility table and the full `wrap` contract. * `?emoji_token_cost` for what the estimate is and is not.