Sentiment analysis

Last updated on 2026-07-21 | Edit this page

Estimated time: 0 minutes

Overview

Questions

  • What is a sentiment?
  • How is sentiment analysis conducted?

Objectives

  • Learn about different lexicons
  • Learn how to add sentiment to words using lexicons
  • Analyse and visualise the sentiments in a text

Sentiment analysis


Sentiment refers to the emotion or tone in a text. It is typically categorised as positive, negative or neutral. Sentiment is often used to analyse opinions, attitudes or emotions in written content. In this case the written content is newspaper articles.

Sentiment analysis is a method used to identify and classify emotions in textual data. This is often done using word list (lexicons). The goal is to determine whether a given text has a positive, negative or neutral tone.

In order to do a sentiment analysis on our data we From the previous section we have a dataset containing a list of words in the text without stopwords. To do a sentiment analysis we can use a so-called lexicon and assign a sentiment to each word. In order to do this we need an list of words and their sentiment. A simple form would be wether they are positive or negative.

There are multiple sentiment lexicons. For a start we will be using the bing lexicon. This lexicon categorizes words as either positive or negative.

R

get_sentiments("bing")

OUTPUT

# A tibble: 6,786 × 2
   word        sentiment
   <chr>       <chr>
 1 2-faces     negative
 2 abnormal    negative
 3 abolish     negative
 4 abominable  negative
 5 abominably  negative
 6 abominate   negative
 7 abomination negative
 8 abort       negative
 9 aborted     negative
10 aborts      negative
# ℹ 6,776 more rows

In order to use the bing-lexicon, we have to save it.

R

bing <- get_sentiments("bing")

We now need to combine the sentiment with the words from the articles. This is done by performing an inner_join.

R

articles_bing <- articles_filtered |> 
  inner_join(bing)

OUTPUT

Joining with `by = join_by(word)`

R

articles_bing

OUTPUT

# A tibble: 123,667 × 8
      id date    section region author                 wordcount word  sentiment
   <dbl> <chr>   <chr>   <chr>  <chr>                      <dbl> <chr> <chr>
 1     1 2026-05 News    UK     Jessica Murray and Ro…      1328 warn… negative
 2     1 2026-05 News    UK     Jessica Murray and Ro…      1328 over… negative
 3     1 2026-05 News    UK     Jessica Murray and Ro…      1328 lagg… negative
 4     1 2026-05 News    UK     Jessica Murray and Ro…      1328 rapid positive
 5     1 2026-05 News    UK     Jessica Murray and Ro…      1328 slow  negative
 6     1 2026-05 News    UK     Jessica Murray and Ro…      1328 warn… negative
 7     1 2026-05 News    UK     Jessica Murray and Ro…      1328 effe… positive
 8     1 2026-05 News    UK     Jessica Murray and Ro…      1328 misu… negative
 9     1 2026-05 News    UK     Jessica Murray and Ro…      1328 over… negative
10     1 2026-05 News    UK     Jessica Murray and Ro…      1328 brea… positive
# ℹ 123,657 more rows

In R, inner_join() is commonly used to combine datasets based on a shared column. In this case it is the word column. inner_join() matches words from a text dataset, in this case articles_filtered with words in the Bing sentiment lexicon to determine whether they are positive or negative.

Once we have the combined dataset we can make a sentiment analysis. A way to start could be by counting the number of positive and negative words used in the articles. This is done per section.

R

articles_bing |> 
  group_by(section) |> 
  summarise(positive = sum(sentiment == "positive"),
            negative = sum(sentiment == "negative"),
            difference = positive - negative) 

OUTPUT

# A tibble: 5 × 4
  section   positive negative difference
  <chr>        <int>    <int>      <int>
1 Arts         11844    13555      -1711
2 Lifestyle    18337    13504       4833
3 News         11216    14220      -3004
4 Opinion       8452    11419      -2967
5 Sport        11350     9770       1580

The result shows the distribution of positive and negative words across sections. It also shows that Lifestyle has the highest number of positive words, and that oponion has the highest number of negative words. The section with the lowest difference is Arts.

Another way of presenting this analysis is by visualising it.

R

articles_bing |> 
  group_by(section, date) |> 
  summarise(positive = sum(sentiment == "positive"),
            negative = sum(sentiment == "negative"),
            difference = positive - negative) |> 
  #ungroup() |> 
  filter(section %in% c("Arts", "Sport")) |> 
  ggplot(mapping = aes(x = date, y = difference, colour = section, group = section)) +
  geom_point() +
  geom_line()

OUTPUT

`summarise()` has regrouped the output.
ℹ Summaries were computed grouped by section and date.
ℹ Output is grouped by section.
ℹ Use `summarise(.groups = "drop_last")` to silence this message.
ℹ Use `summarise(.by = c(section, date))` for per-operation grouping
  (`?dplyr::dplyr_by`) instead.

By looking at the graphs, we can see that the wording in December in Sport articles is quite negative compared to February. If we had a data set covering more years it would be worth investigating if this was a tendency or just a coincidence.

For now, it might be interesting to read the articles from December and February in order to compare their content. To that end we can create a new dataset containing articles from the Sport section from December and February.

R

interesting_articles <- articles |> 
  filter(section == "Sport") |> 
  filter(date %in% c("2025-12", "2026-02"))

This can be saved as csv-file for further analysis.

R

write_csv(interesting_articles, "data_out/interesting_articles.csv")

With bing we only look at the sentiment in a binary fashion - a word is either positive or negative. If we do a similar analysis with AFINN, it looks different. Like bing, AFINN is a sentiment lexicon. It consists of a list of words that are assigned sentiment scores ranging from -5 (very negative) to +5 (very positive). Note that AFINN does not contain a 0.

AFINN is part of the package textdata, so we need to install the package and run the library in order to be able to use it in this script.

R

install.packages("textdata")
library(textdata)

So that we can use the AFINN-lexicon, we have to save it.

R

afinn <- get_sentiments("afinn")

This is what it looks like.

R

afinn

OUTPUT

# A tibble: 2,477 × 2
   word       value
   <chr>      <dbl>
 1 abandon       -2
 2 abandoned     -2
 3 abandons      -2
 4 abducted      -2
 5 abduction     -2
 6 abductions    -2
 7 abhor         -3
 8 abhorred      -3
 9 abhorrent     -3
10 abhors        -3
# ℹ 2,467 more rows

Bemærk at vi ikke på github kan downloade afinn. Derfor har vi downloaded afinn datasættet til en csv-fil pr 21. november 2025. Med andre ord er der risiko for at siden kører med et uopdateret datasæt. - TROR IKKE VI BEHØVER DET MERE - SNAK MED CHRISTIAN

We now need to combine the sentiment to the words from our articles. We do this by performing an inner_join.

R

articles_afinn <- articles_filtered |> 
  inner_join(afinn) 

OUTPUT

Joining with `by = join_by(word)`

Since the AFINN lexicon ascribes either negative or positive numbers each word (instead of strings, as bing does),we can easily calculate the difference as we did with bing.

R

articles_afinn |> 
  group_by(section) |> 
  summarise(different = sum(value))

OUTPUT

# A tibble: 5 × 2
  section   different
  <chr>         <dbl>
1 Arts            730
2 Lifestyle     11230
3 News          -5744
4 Opinion       -4335
5 Sport          8045

It could be interesting to see how the different levels of negative and positive words are used in the different sections.

R

articles_afinn |> 
  #group_by(section) |> 
  count(section, value) |> 
  pivot_wider(names_from = value, values_from = n)

OUTPUT

# A tibble: 5 × 11
  section    `-5`  `-4`  `-3`  `-2`  `-1`   `1`   `2`   `3`   `4`   `5`
  <chr>     <int> <int> <int> <int> <int> <int> <int> <int> <int> <int>
1 Arts         14   203  2122  4807  2718  3189  4287  2037   574    28
2 Lifestyle     3   130  1444  4139  3560  5126  6468  2683   416    32
3 News          1    94  2283  6081  3860  4917  4922   699   155     6
4 Opinion       7    72  1802  4549  2496  2963  3610   721   153     6
5 Sport         9    52  1123  3563  2598  2871  3859  1894  1195    68

This is what it looks like if we add proportions to the calculation.

R

articles_afinn |> 
  count(section, value) |> 
  group_by(section) |>
  mutate(proportion = n / sum(n)) |> 
  ungroup() |> 
  select(-n) |> 
  #arrange(desc(proportion)) |> 
  pivot_wider(names_from = value, values_from = proportion)

OUTPUT

# A tibble: 5 × 11
  section     `-5`    `-4`   `-3`  `-2`  `-1`   `1`   `2`    `3`     `4`     `5`
  <chr>      <dbl>   <dbl>  <dbl> <dbl> <dbl> <dbl> <dbl>  <dbl>   <dbl>   <dbl>
1 Arts     7.01e-4 0.0102  0.106  0.241 0.136 0.160 0.215 0.102  0.0287  1.40e-3
2 Lifesty… 1.25e-4 0.00542 0.0602 0.172 0.148 0.214 0.269 0.112  0.0173  1.33e-3
3 News     4.34e-5 0.00408 0.0992 0.264 0.168 0.214 0.214 0.0304 0.00673 2.61e-4
4 Opinion  4.27e-4 0.00440 0.110  0.278 0.152 0.181 0.220 0.0440 0.00934 3.66e-4
5 Sport    5.22e-4 0.00302 0.0652 0.207 0.151 0.167 0.224 0.110  0.0693  3.95e-3

If we focus on the two sections Sport and Opinion, this is what it looks like when we visualise it by means of ggplot().

R

articles_afinn |> 
  count(section, value) |> 
  group_by(section) |>
  mutate(proportion = n / sum(n)) |> 
  ungroup() |> 
  select(-n) |> 
  filter(section %in% c("Sport", "Opinion"))  |> 
  ggplot(mapping = aes(x = value, y = proportion, fill = section)) +
  geom_col(position = "dodge")

n-grams and correlations

So far we have been looking at words as individual units and not considered how they are related to the other words around them. It is possible to conduct an analysis that looks at the relationsships between words in our text.

Instead of using the unnest_tokens-function at singular word level, as we have done so far, we will tokenize our text in to sequences of words. These sequences are called n-grams. They allow us to see how often a word is followed by another word. This gives us a chance to look at the relationsship between words.

We no longer want to use articles_filteres, since this has been tokenized word by word. We need a dataframe where each article text is in a cell of its own. Therefore, we will go back to our orginal object articles that contains all the articles in their original state.

First we tokenise the text so that each cell contains two words.

R

articles_bigrams <- articles |>
 unnest_tokens(bigram, text, token = "ngrams", n = 2) |> 
 filter(!is.na(bigram))

R

articles_bigrams

OUTPUT

# A tibble: 2,403,402 × 7
      id date    section region author                          wordcount bigram
   <dbl> <chr>   <chr>   <chr>  <chr>                               <dbl> <chr>
 1     1 2026-05 News    UK     Jessica Murray and Robert Booth      1328 brita…
 2     1 2026-05 News    UK     Jessica Murray and Robert Booth      1328 biome…
 3     1 2026-05 News    UK     Jessica Murray and Robert Booth      1328 watch…
 4     1 2026-05 News    UK     Jessica Murray and Robert Booth      1328 have …
 5     1 2026-05 News    UK     Jessica Murray and Robert Booth      1328 warne…
 6     1 2026-05 News    UK     Jessica Murray and Robert Booth      1328 that …
 7     1 2026-05 News    UK     Jessica Murray and Robert Booth      1328 natio…
 8     1 2026-05 News    UK     Jessica Murray and Robert Booth      1328 overs…
 9     1 2026-05 News    UK     Jessica Murray and Robert Booth      1328 of ai
10     1 2026-05 News    UK     Jessica Murray and Robert Booth      1328 ai po…
# ℹ 2,403,392 more rows

It looks much like the result of a tokenisation at singular word level, but the added column is now called bigram and contains two words

We can now count how many times word pair occurs.

R

articles_bigrams |> 
  count(bigram, sort = TRUE)

OUTPUT

# A tibble: 923,094 × 2
   bigram       n
   <chr>    <int>
 1 of the   11380
 2 in the   10393
 3 to the    5100
 4 on the    4680
 5 and the   3755
 6 at the    3717
 7 to be     3575
 8 for the   3464
 9 in a      3211
10 with the  2909
# ℹ 923,084 more rows

Here we can see that the birams that tops the list are pairs of quite common words. Many of these are words that were defined as stopwords earlier. By removing pairs that contain at least one stopwords, we get at more meaningful result.

In order to be able to remove these pairs, we have to put each word in its own column. We can do this by using the function separate.

First we separate each pair into two columns. We to this by using the white space between the words as a separator.

R

bigrams_separated <- articles_bigrams |> 
  separate(bigram, c("word1", "word2"), sep = " ")

bigrams_separated

OUTPUT

# A tibble: 2,403,402 × 8
      id date    section region author                     wordcount word1 word2
   <dbl> <chr>   <chr>   <chr>  <chr>                          <dbl> <chr> <chr>
 1     1 2026-05 News    UK     Jessica Murray and Robert…      1328 brit… biom…
 2     1 2026-05 News    UK     Jessica Murray and Robert…      1328 biom… watc…
 3     1 2026-05 News    UK     Jessica Murray and Robert…      1328 watc… have
 4     1 2026-05 News    UK     Jessica Murray and Robert…      1328 have  warn…
 5     1 2026-05 News    UK     Jessica Murray and Robert…      1328 warn… that
 6     1 2026-05 News    UK     Jessica Murray and Robert…      1328 that  nati…
 7     1 2026-05 News    UK     Jessica Murray and Robert…      1328 nati… over…
 8     1 2026-05 News    UK     Jessica Murray and Robert…      1328 over… of
 9     1 2026-05 News    UK     Jessica Murray and Robert…      1328 of    ai
10     1 2026-05 News    UK     Jessica Murray and Robert…      1328 ai    powe…
# ℹ 2,403,392 more rows

Once that is done, we remove the rows that contain a stopword in either of the two new columns.

R

bigrams_filtered <- bigrams_separated |> 
  filter(!word1 %in% stop_words$word) |> 
  filter(!word2 %in% stop_words$word)

bigrams_filtered

OUTPUT

# A tibble: 479,613 × 8
      id date    section region author                     wordcount word1 word2
   <dbl> <chr>   <chr>   <chr>  <chr>                          <dbl> <chr> <chr>
 1     1 2026-05 News    UK     Jessica Murray and Robert…      1328 brit… biom…
 2     1 2026-05 News    UK     Jessica Murray and Robert…      1328 biom… watc…
 3     1 2026-05 News    UK     Jessica Murray and Robert…      1328 nati… over…
 4     1 2026-05 News    UK     Jessica Murray and Robert…      1328 ai    powe…
 5     1 2026-05 News    UK     Jessica Murray and Robert…      1328 catch crim…
 6     1 2026-05 News    UK     Jessica Murray and Robert…      1328 tech… rapid
 7     1 2026-05 News    UK     Jessica Murray and Robert…      1328 rapid grow…
 8     1 2026-05 News    UK     Jessica Murray and Robert…      1328 metr… poli…
 9     1 2026-05 News    UK     Jessica Murray and Robert…      1328 past  12
10     1 2026-05 News    UK     Jessica Murray and Robert…      1328 12    mont…
# ℹ 479,603 more rows

Now we can make a new count of word pair.

R

bigram_counts <- bigrams_filtered |> 
  count(word1, word2, sort = TRUE)

bigram_counts

OUTPUT

# A tibble: 347,703 × 3
   word1      word2            n
   <chr>      <chr>        <int>
 1 social     media          760
 2 artificial intelligence   392
 3 world      cup            293
 4 chief      executive      256
 5 donald     trump          243
 6 tech       companies      235
 7 south      africa         230
 8 premier    league         207
 9 final      cut            198
10 facial     recognition    191
# ℹ 347,693 more rows

This gives us a more meaningful set of word pairs.

If we want to combine the colums again, so that the words pairs (without stopwords) are back in the same column, it can easily be done by using the function unite

R

bigrams_united <- bigrams_filtered |> 
  unite(bigram, word1, word2, sep = " ")

bigrams_united

OUTPUT

# A tibble: 479,613 × 7
      id date    section region author                          wordcount bigram
   <dbl> <chr>   <chr>   <chr>  <chr>                               <dbl> <chr>
 1     1 2026-05 News    UK     Jessica Murray and Robert Booth      1328 brita…
 2     1 2026-05 News    UK     Jessica Murray and Robert Booth      1328 biome…
 3     1 2026-05 News    UK     Jessica Murray and Robert Booth      1328 natio…
 4     1 2026-05 News    UK     Jessica Murray and Robert Booth      1328 ai po…
 5     1 2026-05 News    UK     Jessica Murray and Robert Booth      1328 catch…
 6     1 2026-05 News    UK     Jessica Murray and Robert Booth      1328 techn…
 7     1 2026-05 News    UK     Jessica Murray and Robert Booth      1328 rapid…
 8     1 2026-05 News    UK     Jessica Murray and Robert Booth      1328 metro…
 9     1 2026-05 News    UK     Jessica Murray and Robert Booth      1328 past …
10     1 2026-05 News    UK     Jessica Murray and Robert Booth      1328 12 mo…
# ℹ 479,603 more rows

Now we can seee how different word pairs, are used in different sections.

R

bigrams_united |> 
  count(section, bigram, sort = TRUE) |> 
  pivot_wider(
    names_from = section,
    values_from = n)

OUTPUT

# A tibble: 347,703 × 6
   bigram                   News Sport Lifestyle Opinion  Arts
   <chr>                   <int> <int>     <int>   <int> <int>
 1 social media              284    36       128     174   138
 2 world cup                   2   284        NA      NA     7
 3 artificial intelligence   262     5        12      64    49
 4 south africa                8   209         2       3     8
 5 premier league              1   203         2      NA     1
 6 final cut                  NA    NA       197      NA     1
 7 chief executive           186    23        17       9    21
 8 john lewis                  3    NA       169       1    10
 9 facial recognition        159    NA         1      26     5
10 tech companies            155    NA         5      57    18
# ℹ 347,693 more rows

We could also have a look at the 10 most used bigrams in the different sections.

R

bigrams_united |> 
  count(section, bigram, sort = TRUE) |> 
  #ungroup() |> 
  group_by(section) |> 
  slice_max(n, n = 10) |> 
  ungroup() |> 
  mutate(bigram = reorder(bigram, n)) |> 
  ggplot(mapping = aes(x = n, y = bigram, fill = section)) +
  geom_col(show.legend = FALSE) +
  facet_wrap(~section, scales = "free", ncol = 2) +
  labs(x = "Contribution to sentiment", 
       y = NULL)
Key Points
  • Sentiments is the emotion or tone in a text
  • There are different lexicons
  • It is possible to add sentiments to words
  • It is possible to visualise the sentiments
  • It is possible to look at relationsship between words