Skip to contents

emoji_incongruity() measures the signed gap between the sentiment a row's emoji carry and the sentiment of its text. It is the sarcasm-detection feature the NLP literature keeps rediscovering, and the mismatch variable the marketing literature calls (in)congruence.

Usage

emoji_incongruity(
  data,
  text,
  text_score,
  method = c("difference", "sign_flip"),
  scale,
  where = c("all", "final"),
  threshold = 1
)

Arguments

data

A data frame or tibble containing a text column. Grouped data frames are accepted. The verbs that work a row at a time (adding columns, or keeping and expanding rows) carry the grouping through to their result, as dplyr::mutate() and dplyr::filter() do. The verbs that pool across rows – the counts, the co-occurrence edge lists, the time series – warn that they ignore the grouping and return one corpus-wide answer.

text

The text column to scan, supplied unquoted. Any atomic column is accepted and read as character, so a factor works and a numeric, Date or logical one simply contains no emoji. A list column – or a data-frame column – is refused rather than coerced, because coercing one deparses it and the emoji found would be in the code rather than in your data. What counts as an emoji is the same in every verb; see the Detection section of tidyEmoji for the one case that surprises people, code points that are emoji only when they carry U+FE0F.

text_score

Unquoted numeric column holding the text's own sentiment.

method

"difference" (default) for the continuous gap, or "sign_flip" for the categorical polarity-flip feature.

scale

How to make the two scores comparable: "rank", "zscore" or "none". Required – there is no sensible default. "rank" and "zscore" are computed over the rows carrying both an emoji score and a text_score, not over the whole corpus, so rows with no scorable emoji cannot shift the answer for the rows that have one.

where

"all" (default) scores every emoji in the row; "final" scores only the trailing run of emoji that ends the text.

threshold

For method = "difference", the absolute gap at or above which .emoji_incongruent is TRUE. Default 1, a full polarity swing on the rank scale. "sign_flip" has no gap to cut, so supplying both warns rather than letting half the call do nothing silently. A threshold at or below 0 flags every scored row, abs(gap) never being negative.

Value

data, as a tibble, with added columns .emoji_n, .emoji_n_scored, .emoji_sentiment, .emoji_incongruity, .emoji_polarity_flip and .emoji_incongruent.

.emoji_n_scored distinguishes the two ways the answer can be missing, as it does in emoji_sentiment(): 0 means the row had emoji that the lexicon could not score, NA that it had no emoji to score. The four derived columns are NA in both cases, and also wherever text_score itself is missing or not finite.

Details

.emoji_incongruity is emoji - text after scaling, so it is positive when the emoji is the more positive of the two. "sign_flip" is the categorical version most sarcasm papers use and is computed on the unscaled scores, where the sign means something.

A row with no scorable emoji gets NA, never 0: a neutral emoji and no emoji at all are different states, and collapsing them silently biases every downstream model. The same applies to a missing text_score, and to an infinite one – a scorer that overflows is reported and treated as missing rather than left to turn every other row's z-score into Inf.

With where = "final" only the run of emoji that ends the text is scored: both the illocutionary-force account of emoji and the P600 evidence on ironic emoji are specifically about sentence-final glyphs. A text whose emoji sit mid-sentence then has nothing eligible to score, so it gets NA and .emoji_n_scored = NA, while .emoji_n still counts every emoji in the row.

"Ends the text" is literal: only whitespace may follow the last glyph, so "great \U0001f602" has a final run and "great \U0001f602." does not – a trailing full stop, bracket or quote mark disqualifies it. The run itself extends back over any glyphs separated from each other by whitespace alone, so "great \U0001f602 \U0001f60d" contributes both. If your corpus punctuates after emoji, strip trailing punctuation before scoring, or use where = "all".

You supply the text score

tidyEmoji deliberately does not score text. text_score is a column you produce with tidytext and AFINN or Bing, sentimentr, vader, or a transformer – which keeps the method choice visible in your script instead of buried in this package, and keeps our dependency footprint where it is.

Because those methods live on wildly different scales (AFINN runs -5 to 5, VADER -1 to 1, a model's logits on nothing in particular), scale has no default: you have to say how the two sides were made comparable. "rank" maps both to percentiles on [-1, 1] and is the safest choice for cross-method comparison; "zscore" standardises both; "none" compares the raw numbers, which is only meaningful if your text score already lives on the emoji lexicon's -1 to 1 scale.

"rank" and "zscore" are computed over the rows the comparison is defined on – those carrying both an emoji score and a text_score – not over the whole corpus. A percentile only means something relative to a population, and the population the gap lives in is the scored subset, so rows with no scorable emoji cannot move the answer for the rows that have one. Subsetting the data to the scored rows before calling therefore gives the same numbers as calling on everything.

That invariance is narrow, and it is worth seeing where it stops. Both scalings are relative, so a gap's size depends on how many scored rows it was computed over and not only on the two scores. Dropping rows that were never scored changes nothing, because they were never in the population; dropping or adding scored rows changes every other row's answer, and so does comparing two corpora of different sizes. The sharpest case is duplicating a scored corpus exactly, which adds no information whatsoever and still multiplies every rank gap by 2 * (n - 1) / (2 * n - 1): 0.933 at n = 8, 0.995 at n = 100, 0.999 at n = 500. ("zscore" moves the other way, by sqrt((2 * n - 1) / (2 * (n - 1))), because sd() divides by n - 1.) So compare gaps within one call, and where a number has to travel between corpora use scale = "none" with a text score already on the emoji lexicon's -1 to 1 scale.

References

An emoji centric approach to sarcasm detection in online discourse. Scientific Reports (2025). The influence of emoji meaning multipleness on perceived online review helpfulness. Journal of Business Research (2022).

See also

emoji_congruence() for the same engine under the marketing framing; emoji_incongruity_profile() for which glyphs go against the grain; emoji_sentiment() for the emoji side on its own.

Examples

df <- data.frame(
  text = c("this is wonderful \U0001f621", "awful \U0001f621", "great \U0001f600"),
  score = c(0.9, -0.8, 0.7)
)
emoji_incongruity(df, text, score, scale = "none")
#> # A tibble: 3 × 8
#>   text        score .emoji_n .emoji_n_scored .emoji_sentiment .emoji_incongruity
#>   <chr>       <dbl>    <int>           <int>            <dbl>              <dbl>
#> 1 this is wo…   0.9        1               1           -0.173             -1.07 
#> 2 awful 😡     -0.8        1               1           -0.173              0.627
#> 3 great 😀      0.7        1               1            0.572             -0.128
#> # ℹ 2 more variables: .emoji_polarity_flip <lgl>, .emoji_incongruent <lgl>
emoji_incongruity(df, text, score, scale = "none", method = "sign_flip")
#> # A tibble: 3 × 8
#>   text        score .emoji_n .emoji_n_scored .emoji_sentiment .emoji_incongruity
#>   <chr>       <dbl>    <int>           <int>            <dbl>              <dbl>
#> 1 this is wo…   0.9        1               1           -0.173             -1.07 
#> 2 awful 😡     -0.8        1               1           -0.173              0.627
#> 3 great 😀      0.7        1               1            0.572             -0.128
#> # ℹ 2 more variables: .emoji_polarity_flip <lgl>, .emoji_incongruent <lgl>