Set 03

Lab 7: Part-of-speech tagging and dependency parsing

In the previous lab, we worked with keyness and effect sizes, specifically using log-likelihood and log ratio measures.

We are now going to add to our toolkit by using the same measures, but applied to data that has been tagged and parsed. To our processing pipeline, we will be adding udpipe.

What does udpipe do?

Before we start processing in R, let’s get some sense of what “universal dependency parsing” is and what its output looks like.

Parse a sample sentence online

Go to this webpage: http://lindat.mff.cuni.cz/services/udpipe/.

And paste the following sentence into the text field:

The company offers credit cards, loans and interest-generating accounts.

Set the model to english-ewt-ud. Then, click the “Process Input” button. You should now see an output. If you choose the “Table” tab, you can view the output in a tablular format:

Id

Form

Lemma

UPosTag

XPosTag

Feats

Head

DepRel

Deps

Misc

1

The

the

DET

DT

Definite=Def|PronType=Art

2

det

_

TokenRange=0:3

2

company

company

NOUN

NN

Number=Sing

3

nsubj

_

TokenRange=4:11

3

offers

offer

VERB

VBZ

Mood=Ind|Number=Sing|Person=3|Tense=Pres|VerbForm=Fin

0

root

_

TokenRange=12:18

4

credit

credit

NOUN

NN

Number=Sing

5

compound

_

TokenRange=19:25

5

cards

card

NOUN

NNS

Number=Plur

3

obj

_

SpaceAfter=No|TokenRange=26:31

6

,

,

PUNCT

,

_

7

punct

_

TokenRange=31:32

7

loans

loan

NOUN

NNS

Number=Plur

5

conj

_

TokenRange=33:38

8

and

and

CCONJ

CC

_

12

cc

_

TokenRange=39:42

9

interest

interest

NOUN

NN

Number=Sing

11

compound

_

SpaceAfter=No|TokenRange=43:51

10

-

-

PUNCT

HYPH

_

11

punct

_

SpaceAfter=No|TokenRange=51:52

11

generating

generate

NOUN

NN

Number=Sing

12

compound

_

TokenRange=52:62

12

accounts

account

NOUN

NNS

Number=Plur

5

conj

_

SpaceAfter=No|TokenRange=63:71

13

.

.

PUNCT

.

_

3

punct

_

SpaceAfter=No|TokenRange=71:72

Basic parse structure

There is a column for the token and one for the token’s base form or lemma.

Those are followed by a tag for the general lexical class or “universal part-of-speech” (upos) tag, and a tree-bank specific (xpos) part-of-speech tag.

The xpos tags are Penn Treebank tags.

The part-of-speech tags are followed by a column of integers that refer to the id of the token that is at the head of the dependency structure, which is followed by the dependency relation identifier.

Visualize the dependency

From the “Output Text” tab, copy the output start with the sent_id including the pound sign

Paste the information into the text field here: https://urd2.let.rug.nl/~kleiweg/conllu/.

Then click the “Submit Query” button below the text field. This should generate a visualization of the dependency structure.

Load the needed packages

library(cmu.textstat)
library(tidyverse)
library(quanteda)
library(quanteda.textstats)
library(udpipe)

Parsing

Preparing a corpus

When we parse texts using a model like ones available in udpipe or spacy, we need to do very little to prepare the corpus. We could trim extra spaces and returns using str_squish() or remove urls, but generally we want the text to be mostly “as is” so the model can do its job.

Download a model

You only need to run this line of code once. To run it, remove the pound sign, run the line, then add the pound sign after you’ve downloaded the model. Or you can run the next chunk and the model will automatically be downloaded in your working directory.

# udpipe_download_model(language = "english")

Annotate a sentence

txt <- "The company offers credit cards, loans and interest-generating accounts."
annotation <- udpipe(txt, "english")

doc_id

paragraph_id

sentence_id

sentence

start

end

term_id

token_id

token

lemma

upos

xpos

feats

head_token_id

dep_rel

deps

misc

doc1

1

1

The company offers credit cards, loans and interest-generating accounts.

1

3

1

1

The

the

DET

DT

Definite=Def|PronType=Art

2

det

NA

NA

doc1

1

1

The company offers credit cards, loans and interest-generating accounts.

5

11

2

2

company

company

NOUN

NN

Number=Sing

3

nsubj

NA

NA

doc1

1

1

The company offers credit cards, loans and interest-generating accounts.

13

18

3

3

offers

offer

VERB

VBZ

Mood=Ind|Number=Sing|Person=3|Tense=Pres|VerbForm=Fin

0

root

NA

NA

doc1

1

1

The company offers credit cards, loans and interest-generating accounts.

20

25

4

4

credit

credit

NOUN

NN

Number=Sing

5

compound

NA

NA

doc1

1

1

The company offers credit cards, loans and interest-generating accounts.

27

31

5

5

cards

card

NOUN

NNS

Number=Plur

3

obj

NA

SpaceAfter=No

doc1

1

1

The company offers credit cards, loans and interest-generating accounts.

32

32

6

6

,

,

PUNCT

,

NA

7

punct

NA

NA

doc1

1

1

The company offers credit cards, loans and interest-generating accounts.

34

38

7

7

loans

loans

NOUN

NNS

Number=Plur

5

conj

NA

NA

doc1

1

1

The company offers credit cards, loans and interest-generating accounts.

40

42

8

8

and

and

CCONJ

CC

NA

12

cc

NA

NA

doc1

1

1

The company offers credit cards, loans and interest-generating accounts.

44

51

9

9

interest

interest

NOUN

NN

Number=Sing

11

compound

NA

SpaceAfter=No

doc1

1

1

The company offers credit cards, loans and interest-generating accounts.

52

52

10

10

-

-

PUNCT

HYPH

NA

11

punct

NA

SpaceAfter=No

doc1

1

1

The company offers credit cards, loans and interest-generating accounts.

53

62

11

11

generating

genera

NOUN

NN

Number=Sing

12

compound

NA

NA

doc1

1

1

The company offers credit cards, loans and interest-generating accounts.

64

71

12

12

accounts

account

NOUN

NNS

Number=Plur

5

conj

NA

SpaceAfter=No

doc1

1

1

The company offers credit cards, loans and interest-generating accounts.

72

72

13

13

.

.

PUNCT

.

NA

3

punct

NA

SpacesAfter=No

Plot the annotation

We can also plot the dependency structure using igraph:

library(igraph)
library(ggraph)

First we’ll create a plotting function.

plot_annotation <- function(x, size = 3){
  stopifnot(is.data.frame(x) & all(c("sentence_id", "token_id", "head_token_id", "dep_rel",
                                     "token_id", "token", "lemma", "upos", "xpos", "feats") %in% colnames(x)))
  x <- x[!is.na(x$head_token_id), ]
  x <- x[x$sentence_id %in% min(x$sentence_id), ]
  edges <- x[x$head_token_id != 0, c("token_id", "head_token_id", "dep_rel")]
  edges <- edges[edges$dep_rel != "punct",]
  edges$head_token_id <- ifelse(edges$head_token_id == 0, edges$token_id, edges$head_token_id)
  nodes = x[, c("token_id", "token", "lemma", "upos", "xpos", "feats")]
  edges$label <- edges$dep_rel
  g <- graph_from_data_frame(edges,
                             vertices = nodes,
                             directed = TRUE)
  ggraph(g, layout = "linear") +
    geom_edge_arc(ggplot2::aes(label = dep_rel, vjust = -0.20), fold = T,linemitre = 2,
                  arrow = grid::arrow(length = unit(3, 'mm'), ends = "last", type = "closed"),
                  end_cap = ggraph::label_rect("wordswordswords"),
                  label_colour = "red", check_overlap = TRUE, label_size = size) +
    geom_node_label(ggplot2::aes(label = token), col = "black", size = size, fontface = "bold") +
    geom_node_text(ggplot2::aes(label = xpos), nudge_y = -0.35, size = size) +
    theme_graph(base_family = "Arial Narrow")
}

And plot the annotation:

plot_annotation(annotation, size = 2.5)

Dependency structure of a sample parsedsentence.

Annotate a corpus

Parsing text is a computationally intensive process and can take time. So for the purposes of this lab, we’ll create a smaller sub-sample of the the data. By adding a column called text_type which includes information extracted from the file names, we can sample 5 texts from each.

set.seed(123)
sub_corpus <- quanteda.extras::sample_corpus %>%
  mutate(text_type = str_extract(doc_id, "^[a-z]+")) %>%
  group_by(text_type) %>%
  sample_n(5) %>%
  ungroup() %>%
  dplyr::select(doc_id, text)

Parallel processing

Parallel processing is a method whereby separate parts of an overall complex task are broken up and run simultaneously on multiple CPUs, thereby reducing the amount of time for processing. Part-of-speech tagging and dependency parsing are computationally intensive, so using parallel processing can save valuable time.

The udpipe() function has an argument for assigning cores: parallel.cores = 1L. It’s easy to set up, so feel free to use that option.

A second option, requires more preparation, but is even faster. So we’ll walk through how it works. First, we will split the corpus based on available cores.

corpus_split <- split(sub_corpus, seq(1, nrow(sub_corpus), by = 10))

For parallel processing in R, we’ll us the package future.apply.

library(future.apply)

Next, we set up our parallel session by specifying the number of cores, and creating a simple annotation function.

ncores <- 4L
plan(multisession, workers = ncores)

annotate_splits <- function(corpus_text) {
  ud_model <- udpipe_load_model("english-ewt-ud-2.5-191206.udpipe")
  x <- data.table::as.data.table(udpipe_annotate(ud_model, x = corpus_text$text,
                                                 doc_id = corpus_text$doc_id))
  return(x)
}

Finally, we annotate using future_lapply. On my machine, this takes roughly 32 seconds.

annotation <- future_lapply(corpus_split, annotate_splits, future.seed = T)

As you might guess, the output is a list of data frames, so we’ll combine them using rbindlist().

annotation <- data.table::rbindlist(annotation)

Process with quanteda

Format the data for quanteda

If we want to do any further processing in quanteda, we need to make a couple of adjustments to our data frame.

anno_edit <- annotation %>%
  dplyr::select(doc_id, sentence_id, token_id, token, lemma, upos, xpos, head_token_id, dep_rel) %>%
  rename(pos = upos, tag = xpos)

anno_edit <- structure(anno_edit, class = c("spacyr_parsed", "data.frame"))

Convert to tokens

sub_tkns <- as.tokens(anno_edit, include_pos = "tag", concatenator = "_")

Create a dfm

We will also extract and assign the variable text_type to the tokens object.

doc_categories <- names(sub_tkns) %>%
  data.frame(text_type = .) %>%
  mutate(text_type = str_extract(text_type, "^[a-z]+"))

docvars(sub_tkns) <- doc_categories

sub_dfm <- dfm(sub_tkns)

And check the frequencies:

feature

frequency

rank

docfreq

group

end

term_id

token_id

token

lemma

upos

xpos

feats

head_token_id

dep_rel

deps

misc

._.

6452

1

40

all

3

1

1

The

the

DET

DT

Definite=Def|PronType=Art

2

det

NA

NA

,_,

5900

2

40

all

11

2

2

company

company

NOUN

NN

Number=Sing

3

nsubj

NA

NA

the_dt

5217

3

40

all

18

3

3

offers

offer

VERB

VBZ

Mood=Ind|Number=Sing|Person=3|Tense=Pres|VerbForm=Fin

0

root

NA

NA

and_cc

2596

4

40

all

25

4

4

credit

credit

NOUN

NN

Number=Sing

5

compound

NA

NA

of_in

2513

5

40

all

31

5

5

cards

card

NOUN

NNS

Number=Plur

3

obj

NA

SpaceAfter=No

a_dt

2256

6

40

all

32

6

6

,

,

PUNCT

,

NA

7

punct

NA

NA

to_to

1702

7

40

all

38

7

7

loans

loans

NOUN

NNS

Number=Plur

5

conj

NA

NA

in_in

1645

8

40

all

42

8

8

and

and

CCONJ

CC

NA

12

cc

NA

NA

i_prp

1497

9

36

all

51

9

9

interest

interest

NOUN

NN

Number=Sing

11

compound

NA

SpaceAfter=No

you_prp

1202

10

36

all

52

10

10

-

-

PUNCT

HYPH

NA

11

punct

NA

SpaceAfter=No

Filter/select tokens

Warning

There are multiple ways to filter/select the tokens we want to count. We could, for example, just filter out all rows in the annotation data frame tagged as PUNCT, if we wanted to exclude punctuation from our counts.

I would, however, advise against altering the original parsed file. We may want to try different options, and we want to avoid having to re-parse our corpus, as that is the most computationally intensive step in the processing pipeline. In fact, if this were part of an actual project, I would advise that you save the parsed data frame as a .csv file using write_csv() or as an .rda file for later use.

We will use the tokens_select() function to either keep or remove tokens based on regular expressions.

sub_dfm <- sub_tkns %>%
  tokens_select("^.*[a-zA-Z0-9]+.*_[a-z]", selection = "keep", valuetype = "regex", case_insensitive = T) %>%
  dfm()

And check the frequencies:

feature

frequency

rank

docfreq

group

end

term_id

token_id

token

lemma

upos

xpos

feats

head_token_id

dep_rel

deps

misc

the_dt

5217

1

40

all

3

1

1

The

the

DET

DT

Definite=Def|PronType=Art

2

det

NA

NA

and_cc

2596

2

40

all

11

2

2

company

company

NOUN

NN

Number=Sing

3

nsubj

NA

NA

of_in

2513

3

40

all

18

3

3

offers

offer

VERB

VBZ

Mood=Ind|Number=Sing|Person=3|Tense=Pres|VerbForm=Fin

0

root

NA

NA

a_dt

2256

4

40

all

25

4

4

credit

credit

NOUN

NN

Number=Sing

5

compound

NA

NA

to_to

1702

5

40

all

31

5

5

cards

card

NOUN

NNS

Number=Plur

3

obj

NA

SpaceAfter=No

in_in

1645

6

40

all

32

6

6

,

,

PUNCT

,

NA

7

punct

NA

NA

i_prp

1497

7

36

all

38

7

7

loans

loans

NOUN

NNS

Number=Plur

5

conj

NA

NA

you_prp

1202

8

36

all

42

8

8

and

and

CCONJ

CC

NA

12

cc

NA

NA

it_prp

1168

9

39

all

51

9

9

interest

interest

NOUN

NN

Number=Sing

11

compound

NA

SpaceAfter=No

is_vbz

1042

10

40

all

52

10

10

-

-

PUNCT

HYPH

NA

11

punct

NA

SpaceAfter=No

If we want to compare one text-type (as our target corpus) to another (as our reference corpus), we can easily subset the data.

acad_dfm <- dfm_subset(sub_dfm, text_type == "acad") %>% dfm_trim(min_termfreq = 1)
fic_dfm <- dfm_subset(sub_dfm, text_type == "fic") %>% dfm_trim(min_termfreq = 1)

And finally, we can generate a keyness table,

acad_v_fic <- keyness_table(acad_dfm, fic_dfm) %>%
  separate(col = Token, into = c("Token", "Tag"), sep = "_")

Token

Tag

LL

LR

PV

AF_Tar

AF_Ref

Per_10.4_Tar

Per_10.4_Ref

DP_Tar

DP_Ref

of

in

254.97151

1.8779434

0

563

159

445.02411

121.078282

0.0937508

0.0673735

japan

nnp

69.77395

6.6685451

0

49

0

38.73212

0.000000

0.7559586

NA

methylation

nn

68.34999

6.6387977

0

48

0

37.94166

0.000000

0.8039681

NA

smoking

nn

66.92604

6.6084241

0

47

0

37.15121

0.000000

0.7826915

NA

china

nnp

58.38229

6.4113872

0

41

0

32.40851

0.000000

0.5975812

NA

japanese

jj

48.41458

6.1412981

0

34

0

26.87535

0.000000

0.7967750

NA

the

dt

40.62914

0.4889023

0

822

608

649.75101

462.991167

0.1385967

0.1397936

by

in

38.86344

1.7319071

0

96

30

75.88333

22.844959

0.1877124

0.1651132

chinese

jj

37.02291

5.7542749

0

26

0

20.55174

0.000000

0.5975812

NA

sauropods

nns

34.17500

5.6387977

0

24

0

18.97083

0.000000

0.7980397

NA

intensity

nn

32.75104

5.5773972

0

23

0

18.18038

0.000000

0.7604898

NA

united

nnp

32.75104

5.5773972

0

23

0

18.18038

0.000000

0.5971860

NA

these

dt

31.63882

3.5563356

0

34

3

26.87535

2.284496

0.3953349

0.6019647

states

nnp

31.32708

5.5132668

0

22

0

17.38993

0.000000

0.5971860

NA

as

in

30.26069

1.4791411

0

94

35

74.30243

26.652452

0.0836651

0.4012032

From that data, we can filter specific lexical classes, like modal verbs:

Token

Tag

LL

LR

PV

AF_Tar

AF_Ref

Per_10.4_Tar

Per_10.4_Ref

DP_Tar

DP_Ref

may

md

3.9877692

1.4323469

0.0458317

13

5

10.2758675

3.8074931

0.1648517

0.4012032

will

md

3.6584302

1.1412981

0.0557862

17

8

13.4376729

6.0919890

0.5387577

0.5992994

ill

md

1.4239582

1.0538352

0.2327530

1

0

0.7904513

0.0000000

0.7967750

NA

ought

md

1.4239582

1.0538352

0.2327530

1

0

0.7904513

0.0000000

0.8004110

NA

must

md

0.1321795

0.3168696

0.7161829

6

5

4.7427081

3.8074931

0.4305193

0.6019647

wo

md

0.0006962

0.0538352

0.9789499

1

1

0.7904513

0.7614986

0.7967750

0.7996497

ca

md

-0.1657799

-0.5311273

0.6838899

2

3

1.5809027

2.2844959

0.7967750

0.5992994

should

md

-0.2169360

-0.3612023

0.6413845

6

8

4.7427081

6.0919890

0.2614813

0.3464819

can

md

-3.3401792

-0.8041458

0.0676072

16

29

12.6472216

22.0834602

0.3475812

0.2237178

might

md

-3.6344817

-1.9461648

0.0565942

2

8

1.5809027

6.0919890

0.5984507

0.6765535

could

md

-6.1468621

-0.9129979

0.0131645

22

43

17.3899296

32.7444411

0.2592573

0.2407274

would

md

-9.2158449

-1.3087349

0.0023993

14

36

11.0663189

27.4139507

0.3131317

0.2149795

’ll

md

-12.9666315

-3.7535197

0.0003171

1

14

0.7904513

10.6609808

0.7967750

0.2393390

’d

md

-32.3838413

-5.5311273

0.0000000

0

24

0.0000000

18.2759671

NA

0.2340339

Extract phrases

We can also extract phrases of specific types. To so so, we first use the function as_phrasemachine() to add a new column to our annotation called phrase_tag.

annotation$phrase_tag <- as_phrasemachine(annotation$upos, type = "upos")

Next, we can use the function keywords_phrases() to extract phrase-types based on regular expressions. Refer to the documentation for suggested regex patterns.

You can also read examples of use cases.

First, we’ll subset our data into annotations by text-type.

acad_anno <- annotation %>% filter(str_detect(doc_id, "acad"))
fic_anno <- annotation %>% filter(str_detect(doc_id, "fic"))
acad_nps <- keywords_phrases(x = acad_anno$phrase_tag, term = tolower(acad_anno$token), 
                          pattern = "(A|N)*N(P+D*(A|N)*N)*", 
                          is_regex = TRUE, detailed = T)


fic_nps <- keywords_phrases(x = fic_anno$phrase_tag, term = tolower(fic_anno$token), 
                             pattern = "(A|N)*N(P+D*(A|N)*N)*", 
                             is_regex = TRUE, detailed = T)

keyword

ngram

pattern

start

end

largest creatures

2

AN

2

3

creatures

1

N

3

3

earth

1

N

9

9

animals

1

N

11

11

apatosaurus

1

N

14

14

aka

1

N

16

16

aka brontosaurus

2

NN

16

17

brontosaurus

1

N

17

17

paleontologists

1

N

19

19

picture

1

N

23

23

they

1

N

26

26

english anatomist

2

AN

35

36

anatomist

1

N

36

36

paleontologist

1

N

39

39

paleontologist richard

2

NN

39

40

Note that although the function uses the term keywords, it is NOT executing a hypothesis test of any kind.

Extract only unique phrases

Note that udpipe extracts overlapping constituents of phrase structures. Normally, we would want only unique phrases. To find those we’ll take advantage of the start and end indexes, using the between() function from the data.table package.

That will generate a logical vector, which we can use to filter out only those phrases that don’t overlap with another.

idx <- seq(1:nrow(acad_nps))

is_unique <- lapply(idx, function(i) sum(data.table::between(acad_nps$start[i], acad_nps$start, acad_nps$end) & data.table::between(acad_nps$end[i], acad_nps$start, acad_nps$end)) == 1) %>% unlist()

acad_nps <- acad_nps[is_unique, ]
idx <- seq(1:nrow(fic_nps))

is_unique <- lapply(idx, function(i) sum(data.table::between(fic_nps$start[i], fic_nps$start, fic_nps$end) & data.table::between(fic_nps$end[i], fic_nps$start, fic_nps$end)) == 1) %>% unlist()

fic_nps <- fic_nps[is_unique, ]

We can also add a rough accounting of the lengths of the noun phrases by summing the spaces and adding 1.

acad_nps <- acad_nps %>%
  mutate(phrase_length = str_count(keyword, " ") + 1)

fic_nps <- fic_nps %>%
  mutate(phrase_length = str_count(keyword, " ") + 1)

keyword

ngram

pattern

start

end

phrase_length

it

1

N

1

1

1

pleasant summer night

3

ANN

4

6

3

wind off the ocean

4

NPDN

10

13

4

trees along copley square

4

NPNN

16

19

4

i

1

N

21

21

1

boston public library

3

NNN

25

27

3

square

1

N

31

31

1

copley plaza bar

3

NNN

37

39

3

first time

2

AN

44

45

2

sammy

1

N

47

47

1

piano

1

N

50

50

1

his music

2

NN

53

54

2

talk of the crowd

4

NPDN

58

61

4

sammy

1

N

64

64

1

ease

1

N

68

68

1

Lab 8: Logistic Regression

This is a script that walks you through basic logistic and multinomial regression. Logistic regression can be applied to binary variables. In our textbook, Brezina walks though an examples applied to lexico-grammar. What are the conditions that predict whether the definite article (“the”) or the indefinite article (“a”) occur? Or predict which relative pronoun (“that” or “which”) is used?

Multinomial regression can be applied to situations where we have more than 2 outcome variables AND those categories are UNORDERED. Here, we’ll look at writing in the Humanities, Sciences, and Social Sciences. There is not inherent order to these categories. If we did have ORDERED categories, we would use ordinal regression. These are very similar procedures, with similar reporting conventions.

We’re going to start by preparing a versions of the that/which data that Brezina describes starting on pg. 130

We have student writing from the US in the MICUSP data and from the UK in the BAWE data. Rather than going through the tagging process, we’ll load in some data that’s already been tagged using udpipe and the Penn-Treebank tagset

https://www.ling.upenn.edu/courses/Fall_2003/ling001/penn_treebank_pos.html

Load the needed packages

library(cmu.textstat)
library(tidyverse)
library(quanteda)
library(nnet)

Case 1

Preparing linguistic data for logistic regression is often a complex process, as it will be for this lab. Our problem comes from Brezina (pg. 130) and concerns that vs. which:

While writing this chapter, I encountered the following situation: my word processor underlines with a wiggly line a phrse that included the relative pronoun which, signaling a potential grammatical error or inaccuracy. The correction off correction offered was to either add a comma in front of which or use the relative pronoun that instead, with the reasoning being as follows: ‘If these words are not essential to the meaning of your sentences, use “which” and separate the words with a comma.’

We are going to mimic Brezina’s experiment, but instead of using the BE06 and AmE06 corpora, we will use the Michigan Corpus of Upper-Level Student Papers (MICUSP) and the British Academic Written English corpus (BAWE).

To save time, the data has already been tagged with udpipe using the follow code.

udmodel <- udpipe_load_model(file = "english-ewt-ud-2.5-191206.udpipe")
target_folder <- "/Users/user/Downloads/bawe_udpipe/"
files_list <- list.files("/Users/user/Downloads/bawe_body", full.names = T)

tag_text <- function(x){
  
  file_name <- basename(x) %>% str_remove(".txt")
  output_file <- paste0(target_folder, file_name, "_udp.txt")
  txt <- readr::read_file(x) %>% str_squish()
  annotation <- udpipe_annotate(udmodel, txt, parser = "none") %>% 
    as.data.frame() %>%
    dplyr::select(token, xpos) %>%
    unite("token", token:xpos)
  
  new_txt <- paste(annotation$token, collapse=" ")
  write.table(new_txt, output_file, quote = F, row.names = F, col.names = F)
  
}

lapply(files_list, tag_text)

Note

In this case, tags are being embedded. That is, the token is being linked to the tag with an underscore: Throughout_IN history_NN. However, the same principle of tagging a text (or a subset of texts) then writing out the result to a local file is one way to handle the computational demands of parsing. For example, you could take the vector of file paths, split it into chunks of say 10, then iterate along those chunks, writing out each result as it progresses. That way, you eliminate the need to put everything into memory at the same time. After the files have been created, they can be joined for analysis.

Prepare tokens

Download and unzip the data from Canvas.

us_files_list <- list.files("/Users/user/Downloads/micusp_udpipe", full.names = T)
uk_files_list <- list.files("/Users/user/Downloads/bawe_udpipe", full.names = T)

We will also read in some metadata. The micusp_meta comes packaged with cmu.textsat, but you will need to download and read in bawe_meta from Canvas.

micusp_meta <- micusp_meta
bawe_meta <- read_csv("/Users/user/Downloads/bawe_meta.csv", show_col_types = FALSE)

For the purposes of the lab, we’ll take a somple of 100 texts from each corpus.

set.seed(123)

us_sample <- sample(us_files_list, 100)
uk_sample <- sample(uk_files_list, 100)

And tokenize our sub-sample.

us_tokens <- us_sample %>%
    readtext::readtext() %>%
    corpus() %>%
    tokens(what = "fastestword")

uk_tokens <- uk_sample %>%
    readtext::readtext() %>%
    corpus() %>%
    tokens(what = "fastestword")

Select token sequences

For this experiment, we want to select sequences that have have noun followed by that or which. Importantly, we also want sequences that have a comma between the noun and the relative pronoun. Imagine a possible phrase like: however, the research [that/which] has been done has focused primarily on. We would want to capture all of these possible combinations:

  • research that

  • research which

  • research, that

  • research, which

We’ll begin by generating what are called skipgrams. A skipgram “skips” over a specified number of tokens. We’ll generate skipgrams 2 to 3 tokens long and skipping over 0 or 1 tokens. Thus, we’ll generate a series of phrases, 2 to 3 tokens in length.

us_grams <- tokens_skipgrams(us_tokens, n = 2:3, skip = 0:1, concatenator = " ")
uk_grams <- tokens_skipgrams(uk_tokens, n = 2:3, skip = 0:1, concatenator = " ")

For our purposes, we don’t need all of these. So we want to begin culling our tokens. First we know that we’re only interested in those ngrams ending with that or which. So we can first identify those.

us_grams <- tokens_select(us_grams, "that_\\S+$|which_\\S+$", selection = "keep",
    valuetype = "regex", case_insensitive = T)

However, we need to sort further. Our tokens of interest can appear in a variety of contexts. For example, “that” frequently appears following a verb of thinking or speaking, as it does here: has_VHZ discussed_VVN that_WDT.

We only want those instances where “that” or “which” is modifying a noun, as in this example: belief_NN that_WDT.

So next, we’ll select only those ngrams that being with a word that’s been tagged as a noun (having the _NN tag).

us_grams <- tokens_select(us_grams, "^[a-z]+_NN\\d?", selection = "keep",
    valuetype = "regex", case_insensitive = T)

Finally, we want only those 3 token sequences that have a medial comma like: earth_NN ,_, that_WDT

us_grams <- tokens_select(us_grams, "\\s[^,]+_[^,]+\\s", selection = "remove",
    valuetype = "regex", case_insensitive = T)

So let’s repeat the sorting process with the UK data.

uk_grams <- uk_grams %>%
    tokens_select("that_\\S+$|which_\\S+$", selection = "keep", valuetype = "regex",
        case_insensitive = T) %>%
    tokens_select("^[a-z]+_NN\\d?", selection = "keep", valuetype = "regex",
        case_insensitive = T) %>%
    tokens_select("\\s[^,]+_[^,]+\\s", selection = "remove", valuetype = "regex",
        case_insensitive = T)

Structuring the data

Now let’s convert our data structure to a data frame.

us_grams <- data.frame(feature = unlist(us_grams), stringsAsFactors = F)
uk_grams <- data.frame(feature = unlist(uk_grams), stringsAsFactors = F)

We’re going to follow Brezina’s recommendation on pg. 122 for stucturing and idenfiying our variables using some prefixing.

us_grams <- us_grams %>%
    rownames_to_column("doc_id") %>%
    mutate(doc_id = str_replace(doc_id, "_\\S+$", "")) %>%
    mutate(comma_sep = ifelse(str_detect(feature, ",") == T, "B_yes", "A_no")) %>%
    mutate(rel_type = ifelse(str_detect(feature, "that_") == T, "A_that",
        "B_which"))

uk_grams <- uk_grams %>%
    rownames_to_column("doc_id") %>%
    mutate(doc_id = str_replace(doc_id, "_\\S+$", "")) %>%
    mutate(comma_sep = ifelse(str_detect(feature, ",") == T, "B_yes", "A_no")) %>%
    mutate(rel_type = ifelse(str_detect(feature, "that_") == T, "A_that",
        "B_which"))

Now let’s join some metadata. We’ll select the speaker_status variable. Again, we’ll clean it using some prefixing. Finally, we’ll add a column that identifyies the location as being in US.

us_grams <- us_grams %>%
    left_join(select(micusp_meta, doc_id, speaker_status), by = "doc_id") %>%
    mutate(speaker_status = str_replace(speaker_status, "NNS", "B_NNS")) %>%
    mutate(speaker_status = str_replace(speaker_status, "^NS$", "A_NS")) %>%
    mutate(nat_id = "A_US")

The UK data doesn’t have a speaker_status column but it does have a first_language column. We can make a column that matches the US data using ifelse().

uk_grams <- uk_grams %>%
    left_join(dplyr::select(bawe_meta, doc_id, speaker_l1), by = "doc_id") %>%
    mutate(speaker_l1 = ifelse(str_detect(speaker_l1, "English") == T,
        "A_NS", "B_NNS")) %>%
    rename(speaker_status = speaker_l1) %>%
    mutate(nat_id = "B_UK")

Logistic regression model

Before running the regression model, we can combine the two tables. We also need to convert some character columns into factors (or categorical variables).

rel_data <- bind_rows(us_grams, uk_grams) %>%
    mutate_at(3:6, factor)

To understand how to set up the model, it might help to refer to pg. 119. Our outcome variable is that/which or the rel_type column. For logistic regression, this must be binary. For our first model, we’ll set up 3 predictor variables:

  • comma_sep: whether or not a comma appears between the noun and the relative pronoun

  • nat_id: whether the student is in the US or the UK

  • speaker_status: whether the student is a native speaker of English or not

glm_fit <- glm(rel_type ~ comma_sep + nat_id + speaker_status, data = rel_data,
    family = "binomial")

Evaluating the model

Logistic regression has some prerequisites and assumptions. One of which is there is no colinearity between predictors. One tool for colinearity diagnostics is VIF. VIF (or variance inflation factors) measure the inflation in the variances of the parameter estimates due to collinearities that exist among the predictors and range from 1 upwards.

The numerical value for VIF tells you (in decimal form) what percentage the variance is inflated for each coefficient. For example, a VIF of 1.9 tells you that the variance of a particular coefficient is 90% bigger than what you would expect if there was no multicollinearity — if there was no correlation with other predictors.

car::vif(glm_fit)
#>      comma_sep         nat_id speaker_status 
#>       1.001332       1.079536       1.078434

Now let’s look at odds ratios. We can calculate the odds ratio by exponentiating the coefficients (or log odds). For example on pg. 125 of Brezina, he shows an estimate of 6.802 for Context_type B_determined. If we were to exponentiate that value:

exp(6.802)

We would get odd roughly equal to 900, as Brezina’s table shows. So here we could calculate our odds ratios and our confidence intervals:

exp(cbind(OR = coef(glm_fit), confint(glm_fit))

For a nicely formatted output, we can use the jtools package to put those values into a table.

jtools::export_summs(glm_fit, exp = TRUE, error_format = "[{conf.low}, {conf.high}]")
                                  ─────────────────────────────────────────────────
                                                                   Model 1         
                                                          ─────────────────────────
                                    (Intercept)                          0.33 ***  
                                                                  [0.31, 0.36]     
                                    comma_sepB_yes                       4.91 ***  
                                                                  [4.29, 5.61]     
                                    nat_idB_UK                           1.43 ***  
                                                                  [1.28, 1.60]     
                                    speaker_statusB_NNS                  1.02      
                                                                  [0.90, 1.17]     
                                                          ─────────────────────────
                                    N                                 6759         
                                    AIC                               8140.99      
                                    BIC                               8168.26      
                                    Pseudo R2                            0.12      
                                  ─────────────────────────────────────────────────
                                    *** p < 0.001; ** p < 0.01; * p < 0.05.        

While no exact equivalent to the R2 of linear regression exists, an R2 index can be used to assess the model fit. Note that pseudo R2 have been critiqued for their lack of accuracy(see Brezina pg. 125).

We can also generate what Brezina calls a C-index. The C-index is the area under an ROC. The ROC is a curve generated by plotting the true positive rate (TPR) against the false positive rate (FPR) at various threshold settings while the AUC is the area under the ROC curve. As a rule of thumb, a model with good predictive ability should have an AUC closer to 1 (1 is ideal) than to 0.5. These can be calculated and plotted using packages like ROCR. You can find examples of the plots and the resulting AUC values as in this one:

https://www.r-bloggers.com/a-small-introduction-to-the-rocr-package/

For our purposes we’ll just use the Cstat() function from DescTools.

DescTools::Cstat(glm_fit)
#> [1] 0.6478916

Case 2

Let’s trying making another model. This time, we’ll just use the untaggged MICUSP data. Note that our choices are a little different here. We’re leaving in punctuation and numbers. This is because we’ll be doing something a little different with this tokens object.

Prepare the data

micusp_tokens <- readtext::readtext("/Users/user/Downloads/micusp_body") %>%
    corpus() %>%
    tokens(include_docvars = T, remove_punct = F, remove_numbers = F, remove_symbols = T,
        what = "word")

Now we load in a dictionary. This dictionary has almost 15,000 entries. It is organized into only 2 categories: phrases that communicate high confidence and those that communicate hedged confidence. Go and look at the Hyland article on stance and engagement for more on the importance of these kinds of features to academic writing.

hb_dict <- dictionary(file = "/Users/user/Downloads/hedges_boosters.yml")

Next we create a new tokens object from our original one. By using tokens_lookup() with our dictionary, we will create groupings based on our dictionary. Note that our dictionary has only 1 level. But if we can a more complex taxonomy, we can specify which level of the taxonomy we’d like to group our tokens under.

hb <- micusp_tokens %>%
    tokens_lookup(dictionary = hb_dict, levels = 1) %>%
    dfm() %>%
    convert(to = "data.frame") %>%
    mutate(tokens_total = ntoken(micusp_tokens), hedges_norm = (confidencehedged/tokens_total) *
        100, boosters_norm = (confidencehigh/tokens_total) * 100, )

Check assumptions

This time we’ll do a quick check for colinearity before building the model by calculating the correlation between frequencies of hedges and boosters

cor(hb$hedges_norm, hb$boosters_norm)
#> [1] 0.1736008

How does this look? Check Brezina pg. 121.

Now lets check some distributions. First we’ll create a data structure for ggplot.

hb_df <- hb %>%
    select(hedges_norm, boosters_norm) %>%
    pivot_longer(everything(), names_to = "confidence", values_to = "freq_norm")

Now plot histograms.

ggplot(hb_df, aes(x = freq_norm, color = confidence, fill = confidence)) +
    geom_histogram(bins = 10, alpha = 0.5, position = "identity") + theme_classic() +
    theme(axis.text = element_text(size = 5)) + facet_wrap(~confidence)

Histograms of hedging and boosing tokens inMICUSP.

And boxplots.

ggplot(hb_df, aes(x = confidence, y = freq_norm)) + geom_boxplot() + xlab("") +
    ylab("Frequency (per 100 tokens)") + scale_x_discrete(labels = c("Boosters",
    "Hedges")) + theme_classic() + coord_flip()

Frequencies (per 100 tokens) of hedges and boosters inMICUSP.

How do these look to you?

Format the data

Now let’s create some data for our regression models. For this, we’ll combine our frequency counts with some metadata: discipline category, speaker status, gender, and paper type. We’ll also move the text_id to row names to exclude that column from further processing.

lr_df <- hb %>%
    mutate(doc_id = str_remove_all(doc_id, ".txt")) %>%
    dplyr::select(doc_id, hedges_norm, boosters_norm) %>%
    left_join(select(micusp_meta, doc_id, discipline_cat, speaker_status,
        student_gender, paper_type), by = "doc_id") %>%
    remove_rownames %>%
    column_to_rownames(var = "doc_id")

For the mulinomial regression, we’re going to want to collapse all of the discipline categories into 3: Science, Humanities, and Social Science.

lr_df$discipline_cat <- str_replace_all(lr_df$discipline_cat, "BIO|CEE|ECO|IOE|MEC|NRE|PHY",
    "SCI")
lr_df$discipline_cat <- str_replace_all(lr_df$discipline_cat, "CLS|ENG|HIS|PHI",
    "HUM")
lr_df$discipline_cat <- str_replace_all(lr_df$discipline_cat, "ECO|EDU|LIN|NUR|POL|PSY|SOC",
    "SOCSCI")

To carry out our regression, we need to convert our character columns to factors. In other words, they need to be treated like categories not strings. We can do them all with one simple line of code.

lr_df <- lr_df %>%
    mutate_if(is.character, as.factor)

Logistic regression model

We’ll start with student gender as our outcome variable and hedges and boosters as our predictors. The family argument specifies logistic regression.

glm_fit1 <- glm(student_gender ~ boosters_norm + hedges_norm, data = lr_df,
    family = "binomial")

And we do something similar for speaker status.

glm_fit2 <- glm(speaker_status ~ boosters_norm + hedges_norm, data = lr_df,
    family = "binomial")

And finally, let’s repeat this process with a subset of our data. We have 3 discipline categories, so let’s subset out only 2.

lr_sub <- lr_df %>%
    filter(discipline_cat == "HUM" | discipline_cat == "SCI")
lr_sub$discipline_cat <- droplevels(lr_sub$discipline_cat)

glm_fit3 <- glm(discipline_cat ~ boosters_norm + hedges_norm, data = lr_sub,
    family = "binomial")
                      ────────────────────────────────────────────────────────────────────────
                                            Model 1           Model 2           Model 3       
                                      ────────────────────────────────────────────────────────
                        (Intercept)            0.50 ***          3.45 ***           6.27 ***  
                                        [0.35, 0.70]      [2.19, 5.43]      [3.66, 10.74]     
                        boosters_norm          1.29              1.92 *             0.03 ***  
                                        [0.86, 1.91]      [1.03, 3.56]       [0.01, 0.06]     
                        hedges_norm            1.07              0.95               1.81 **   
                                        [0.81, 1.43]      [0.66, 1.37]       [1.20, 2.73]     
                                      ────────────────────────────────────────────────────────
                        N                    828               828                462         
                        AIC                 1103.01            775.59             517.76      
                        BIC                 1117.17            789.75             530.16      
                        Pseudo R2              0.00              0.01               0.28      
                      ────────────────────────────────────────────────────────────────────────
                        *** p < 0.001; ** p < 0.01; * p < 0.05.                               

Multinomial regression model

Now let’s try multinomial regression on all 3 of the discipline categories. This isn’t covered in the textbook, but it’s worth looking at even if briefly.

mr_fit <- multinom(discipline_cat ~ boosters_norm + hedges_norm, data = lr_df)
#> # weights:  12 (6 variable)
#> initial  value 909.650975 
#> iter  10 value 814.149632
#> final  value 814.142751 
#> converged

We first see that some output is generated by running the model, even though we are assigning the model to a new R object. This model-running output includes some iteration history and includes the final log-likelihood 814.275451. This value multiplied by two is then seen in the model summary as the Residual Deviance and it can be used in comparisons of nested models.

Let’s look at a couple of boxplots to give us some context for these numbers.

ggplot(lr_df, aes(x = reorder(discipline_cat, boosters_norm, FUN = median),
    y = boosters_norm)) + geom_boxplot() + xlab("") + ylab("Boosters (per 100 tokens)") +
    theme_classic() + coord_flip()

Boxplots of boosting tokens by disciplinary category inMICUSP.

ggplot(lr_df, aes(x = reorder(discipline_cat, hedges_norm, FUN = median),
    y = hedges_norm)) + geom_boxplot() + xlab("") + ylab("Hedges (per 100 tokens)") +
    theme_classic() + coord_flip()

Boxplots of hedging tokens by disciplinary category inMICUSP.

Much like logistic regression, th ratio of the probability of choosing one outcome category over the probability of choosing the baseline category is the relative risk or odds. The relative risk is the right-hand side linear equation exponentiated, leading to the fact that the exponentiated regression coefficients are relative risk ratios for a unit change in the predictor variable. We can exponentiate the coefficients from our model to see these odds ratios.

                                  ─────────────────────────────────────────────────
                                                                   Model 1         
                                                          ─────────────────────────
                                    (Intercept)                          1.99 ***  
                                                                  [1.45, 2.53]     
                                    boosters_norm                       -3.92 ***  
                                                                [-4.71, -3.13]     
                                    hedges_norm                          0.59 **   
                                                                  [0.16, 1.01]     
                                                          ─────────────────────────
                                    nobs                               828         
                                    edf                                  6.00      
                                    deviance                          1628.29      
                                    AIC                               1640.29      
                                    nobs.1                             828.00      
                                  ─────────────────────────────────────────────────
                                    *** p < 0.001; ** p < 0.01; * p < 0.05.        

Sometimes a plot can be helpful in interpreting the results. Let’s start by making some dummy data. For this we’ll sequence frequencies of hedges from 0 to 6 percent of a text, and frequencies of boosters on an inverse scale: from 6 to 0 percent of a text. In essence, we creating hypothetical texts that have at one end have low frequencies of hedges and high frequencies of boosters, have balanced frequencies in the middle, and have high frequencies of hedges and low frequencies of boosters.

hb_new <- data.frame(hedges_norm = seq(0, 6, by = 0.1), boosters_norm = seq(6,
    0, by = -0.1))

Next, we create a data frame of discipline probabilities based on our fit.

prob_disc <- cbind(hb_new, predict(mr_fit, newdata = hb_new, type = "probs",
    se = TRUE))

We’ll format a data frame for plotting.

plot_prob <- prob_disc %>%
    pivot_longer(hedges_norm:boosters_norm, names_to = "feature", values_to = "confidence") %>%
    pivot_longer(HUM:SOCSCI, names_to = "variable", values_to = "probability")

Finally, we’ll create a plot the probabilities and color by hedges & boosters.

ggplot(plot_prob, aes(x = confidence, y = probability, color = feature)) +
    geom_line() + theme_classic() + facet_grid(variable ~ ., scales = "free")

Predicted probabilities across token frequencies for hedging andboosting facetted by disciplinarycategory.

Lab 9: Multi-Dimensional Analysis

Multi-Dimensional Analysis (MDA) is a process made up of 4 main steps:

  1. Identification of relevant variables

  2. Extraction of factors from variables

  3. Functional interpretation of factors as dimensions

  4. Placement of categories on the dimensions

It is also a specific application of factor analysis. Factor analysis is a method(s) for reducing complexity in linguistic data, which can identify underlying principles of systematic variation (Biber 1988).

library(cmu.textstat)
library(tidyverse)
library(quanteda)
library(nFactors)

Case 1: Biber Tagger

In order to carry out MDA, we would like to have 5 times as many observations than variables. This generally precludes carrying out MDA (or factor analysis) with simple word counts. We need data that has, in some way, been tagged.

For this lab, we will use data prepared using the R package pseudobibeR, which emulates the classification system that Biber has used and reported in much of his research. The package aggregates the lexicogrammatical and functional features widely used for text-type, register, and genre classification tasks.

The scripts are not really taggers. Rather, they use udpipe or spaCy part-of-speech tagging and dependency parsing to summarize patterns. They organize 67 categories.

For this lab, you won’t need to use the package functions. But if you’d like to try it out for any of your projects, you can follow the instructions.

The Brown Corpus

Let’s start with counts from the Brown Corpus. The Brown family of corpora is discussed on pg. 16 of Brezina. You can also find more about it here:

http://icame.uib.no/brown/bcm.html

bc <- read_csv("https://raw.githubusercontent.com/browndw/cmu-textstat-docs/main/docs/_static/labs_files/data-csv/bc_biber.csv",
    show_col_types = FALSE)

bc_meta <- read_csv("https://raw.githubusercontent.com/browndw/cmu-textstat-docs/main/docs/_static/labs_files/data-csv/brown_meta.csv",
    show_col_types = FALSE)

We will join the data with the metadata, in order to calculate dimension scores by register and evaluate them.

Note

The categorical variable must be formatted as a factor. For convenience sake, we’ll move the file names to the row names and put our factor as the first column.


``` r
bc <- bc %>%
    left_join(dplyr::select(bc_meta, doc_id, text_type)) %>%
    mutate(text_type = as.factor(text_type)) %>%
    column_to_rownames("doc_id") %>%
    dplyr::select(text_type, everything())

Correlation matrix

Before calculating our factors, let’s check a correlation matrix. Note that we’re dropping the first (factor) column.

bc_cor <- cor(bc[-1], method = "pearson")
corrplot::corrplot(bc_cor, type = "upper", order = "hclust", tl.col = "black",
    tl.srt = 45, diag = F, tl.cex = 0.5)

Correlation matrix of lexico-grammaticalcategories.

Determining number of factors

Typically, the number of factors is chosen after inspecting a scree plot.

screeplot_mda(bc)

Scree plot offactors.

A common method for interpreting a scree plot is to look for the “bend” in the elbow, which would be 3 or 4 factors in this case. We can also look at the results of other kinds of solutions like optimal coordinates, which measures the gradients associated with eigenvalues and their preceding coordinates, and acceleration factor, which determines the coordinate where the slope of the curve changes most abruptly. In this case OC suggests 6 factors and AF 1.

For the purposes of this exercise, we’ll start with 3 factors.

Calculating factor loadings and MDA scores

In factor analysis factors so that they pass through the middle of the relevant variables. For linguistic variable it is conventional to use a promax rotation (see Brezina pgs. 164-167). There is also a nice explanation of rotations here:

https://personal.utdallas.edu/~herve/Abdi-rotations-pretty.pdf

To place our categories along the dimensions, data is standardized by converting to z-scores. For each text, a dimension score is calculated by summing all of the high-positive variables subtracting all of the high-negative variables. Then, the mean is calculated for each category.

For these calculations, we will use the mda_loadings() function.

bc_mda <- mda_loadings(bc, n_factors = 3)

We can access factor loadings and group means through attributes: attr(bc_mda, "loadings")

Factor1

Factor2

Factor3

f_01_past_tense

-0.15

1.10

0.16

f_02_perfect_aspect

0.05

0.52

0.28

f_03_present_tense

0.60

-1.06

-0.04

f_04_place_adverbials

0.17

0.42

-0.11

f_05_time_adverbials

0.23

0.32

0.06

f_06_first_person_pronouns

0.44

0.15

0.22

f_07_second_person_pronouns

0.66

-0.11

-0.14

f_08_third_person_pronouns

0.19

0.73

0.16

f_09_pronoun_it

0.32

0.11

0.39

f_10_demonstrative_pronoun

0.36

-0.15

0.37

f_11_indefinite_pronouns

0.49

0.30

0.26

f_12_proverb_do

0.54

0.04

0.14

f_13_wh_question

0.39

0.10

0.05

f_14_nominalizations

-0.51

-0.39

0.21

f_15_gerunds

-0.02

-0.16

-0.03

f_16_other_nouns

-0.29

-0.32

-0.77

f_17_agentless_passives

-0.48

-0.21

0.00

f_18_by_passives

-0.45

-0.16

0.02

f_19_be_main_verb

0.46

-0.09

0.41

f_20_existential_there

0.13

0.13

0.30

f_21_that_verb_comp

-0.18

0.08

0.44

f_22_that_adj_comp

0.01

-0.10

0.38

f_23_wh_clause

0.36

0.23

0.22

f_24_infinitives

0.10

0.01

0.24

f_25_present_participle

0.12

0.48

-0.03

f_27_past_participle_whiz

-0.53

0.08

-0.11

f_28_present_participle_whiz

-0.22

0.03

-0.16

f_30_that_obj

0.00

-0.11

0.26

f_31_wh_subj

-0.14

-0.10

0.14

f_32_wh_obj

-0.03

0.04

0.27

f_33_pied_piping

-0.17

-0.17

0.34

f_34_sentence_relatives

-0.21

-0.07

0.08

f_35_because

0.33

-0.13

0.15

f_36_though

-0.20

0.16

0.32

f_37_if

0.53

-0.27

0.12

f_38_other_adv_sub

0.02

-0.06

0.33

f_39_prepositions

-0.68

-0.23

-0.06

f_40_adj_attr

-0.41

-0.47

0.18

f_41_adj_pred

0.14

0.16

0.26

f_42_adverbs

0.57

0.21

0.51

f_43_type_token

0.14

0.12

-0.06

f_44_mean_word_length

-0.65

-0.36

0.04

f_45_conjuncts

-0.20

-0.41

0.37

f_46_downtoners

0.13

-0.03

0.38

f_47_hedges

0.40

0.10

0.24

f_48_amplifiers

0.04

-0.11

0.36

f_49_emphatics

0.41

-0.26

0.26

f_50_discourse_particles

0.41

0.07

0.00

f_51_demonstratives

-0.10

-0.31

0.31

f_52_modal_possibility

0.45

-0.39

0.29

f_53_modal_necessity

0.10

-0.36

0.19

f_54_modal_predictive

0.43

-0.12

-0.09

f_55_verb_public

0.07

0.38

0.09

f_56_verb_private

0.25

0.44

0.44

f_57_verb_suasive

-0.12

0.03

0.03

f_58_verb_seem

-0.02

0.13

0.39

f_59_contractions

0.61

0.25

-0.03

f_60_that_deletion

0.25

0.26

0.03

f_63_split_auxiliary

-0.04

-0.07

0.37

f_64_phrasal_coordination

-0.15

-0.39

-0.02

f_65_clausal_coordination

0.47

0.37

0.28

f_66_neg_synthetic

0.07

0.32

0.35

f_67_neg_analytic

0.57

0.20

0.38

Plotting the results

The means are conventionally positioned on a stick plot of the kind Brezina shows on pg. 169.

mda.biber::stickplot_mda(bc_mda, n_factor = 1)

Deminsion score means by discipline plotted along Factor1.

We can also show the same plot with the factor loadings.

mda.biber::heatmap_mda(bc_mda, n_factor = 1)

Deminsion score means by discipline plotted along Factor1.

Evaluating MDA

Typically, MDA is evaluated using ANOVA, reporting the F statistic, degrees of freedom, and R-squared. We can extract that information from a linear model.

f_aov <- aov(Factor1 ~ group, data = bc_mda)
broom::tidy(f_aov)
#> # A tibble: 2 × 6
#>   term         df  sumsq meansq statistic   p.value
#>   <chr>     <dbl>  <dbl>  <dbl>     <dbl>     <dbl>
#> 1 group        14 51553.  3682.      32.1  3.49e-60
#> 2 Residuals   485 55651.   115.      NA   NA
f1_lm <- lm(Factor1 ~ group, data = bc_mda)
names(f1_lm$coefficients) <- names(coef(f1_lm)) %>%
    str_remove("group")
f2_lm <- lm(Factor2 ~ group, data = bc_mda)
names(f2_lm$coefficients) <- names(coef(f2_lm)) %>%
    str_remove("group")
f3_lm <- lm(Factor3 ~ group, data = bc_mda)
names(f3_lm$coefficients) <- names(coef(f3_lm)) %>%
    str_remove("group")
                     ──────────────────────────────────────────────────────────────────────────
                                                       Factor 1      Factor 2      Factor 3    
                                                    ───────────────────────────────────────────
                       (Intercept)                     -1.24         -0.81          1.73 *     
                       FICTION: ADVENTURE              13.62 ***     14.80 ***     -0.81       
                       FICTION: GENERAL                11.73 ***     13.40 ***     -0.23       
                       FICTION: MYSTERY                20.43 ***     14.38 ***      3.10       
                       FICTION: ROMANCE                21.90 ***     14.09 ***      2.93 *     
                       FICTION: SCIENCE                16.03 ***      8.13 **       5.42       
                       HUMOR                           12.25 **       7.28 ***      2.94       
                       LEARNED                         -9.66 ***     -7.75 ***     -0.11       
                       MISCELLANEOUS: GOVERNMENT &    -14.14 ***     -9.57 ***     -9.19 ***   
                       HOUSE ORGANS                                                            
                       POPULAR LORE                    -1.21         -0.58         -2.37       
                       PRESS: EDITORIAL                 3.17         -2.28         -0.87       
                       PRESS: REPORTAGE                -6.94 ***      0.71        -10.37 ***   
                       PRESS: REVIEWS                  -0.57         -2.89         -3.85 *     
                       RELIGION                         3.33         -3.82 *        4.29 *     
                       SKILL AND HOBBIES               -0.51         -5.56 ***     -5.09 ***   
                                                    ───────────────────────────────────────────
                       DF                             485.00        485.00        485.00       
                       R2                               0.48          0.66          0.27       
                       F statistic                     32.09         67.70         13.00       
                     ──────────────────────────────────────────────────────────────────────────
                       *** p < 0.001; ** p < 0.01; * p < 0.05.                                 

Case 2: DocuScope

Unlike the Biber tagger, DocuScope is a dictionary based tagger. It has been developed at CMU by David Kaufer and Suguru Ishizaki since the early 2000s.

Load the dicitonary

DocuScope is a very large dictionary (or lexicon) that organizes tens of millions of words and phrases into rhetorically oriented categories. It has some overlap with a few Biber’s functional categories (like hedges), but is fundamentally different, as it isn’t bases on parts-of-speech.

The ds_dict is a small quanteda dictionary that organizes a reduced set of words of phrases (tens of thousands rather than tens of millions). Here is a sample from 3 of the categories:

ds_dict[1:3]
#> Dictionary object with 3 key entries.
#> - [AcademicTerms]:
#>   - a chapter in, a couple, a declaration of, a detail, a distinction between, a domain, a force, a forced, a form of, a grade, a hint of, a home for, a hub, a kind of, a kind of a, a load, a loaded, a metaphor for, a mix of, a mixture of [ ... and 8,884 more ]
#> - [AcademicWritingMoves]:
#>   - . in this article ,, . in this paper, . this essay, . this paper, . this report, . this work, . to avoid, a better understanding, a common problem, a debate about, a debate over, a first step, a goal of, a great deal of attention, a huge problem, a key to, a major problem, a method of, a notion that, a number of studies [ ... and 1,141 more ]
#> - [Character]:
#>   - ; block, ; bring, ; call, ; center, ; check, ; chill, ; close, ; color, ; control, ; cook, ; cool, ; cover, ; cross, ; cut, ; design, ; discard, ; don, ; down, ; drain, ; e-mail [ ... and 18,754 more ]

Tokenize the corpus

Again, we will use the micusp_mini, and we’ll begin by tokenizing the data. Note that we’re retaining as much of the original data as possible including punctuation. This is because our dictionary includes punctuation marks in it’s entries.

micusp_tokens <- micusp_mini %>%
    corpus() %>%
    tokens(remove_punct = F, remove_numbers = F, remove_symbols = F, what = "word")

Next, we will use the tokens_lookup() function to count and categorize our features.

ds_counts <- micusp_tokens %>%
    tokens_lookup(dictionary = ds_dict, levels = 1, valuetype = "fixed") %>%
    dfm() %>%
    convert(to = "data.frame") %>%
    as_tibble()

Finally, we need to normalize the counts. Because DocuScope is not categorizing ALL of our tokens, we need a total count from the original tokens object.

tot_counts <- quanteda::ntoken(micusp_tokens) %>%
    data.frame(tot_counts = .) %>%
    tibble::rownames_to_column("doc_id") %>%
    dplyr::as_tibble()

ds_counts <- dplyr::full_join(ds_counts, tot_counts, by = "doc_id")

Now we can normalize by the total counts before preparing the data for factor analysis.

ds_counts <- ds_counts %>%
    dplyr::mutate_if(is.numeric, list(~./tot_counts), na.rm = TRUE) %>%
    dplyr::mutate_if(is.numeric, list(~. * 100), na.rm = TRUE) %>%
    dplyr::select(-tot_counts)

ds_counts <- ds_counts %>%
    mutate(text_type = str_extract(doc_id, "^[A-Z]+")) %>%
    mutate(text_type = as.factor(text_type)) %>%
    column_to_rownames("doc_id")

Calculating factor loadings and MDA score

Again, we will use 3 factors.

micusp_mda <- mda_loadings(ds_counts, n_factors = 3)

Evaluating MDA

We can again check to see how explanatory our dimensions are.

f1_lm <- lm(Factor1 ~ group, data = micusp_mda)
names(f1_lm$coefficients) <- names(coef(f1_lm)) %>%
    str_remove("group")
f2_lm <- lm(Factor2 ~ group, data = micusp_mda)
names(f2_lm$coefficients) <- names(coef(f2_lm)) %>%
    str_remove("group")
f3_lm <- lm(Factor3 ~ group, data = micusp_mda)
names(f3_lm$coefficients) <- names(coef(f3_lm)) %>%
    str_remove("group")
                              ─────────────────────────────────────────────────────────
                                               Factor 1      Factor 2      Factor 3    
                                            ───────────────────────────────────────────
                                (Intercept)    -2.81 **       4.44 ***     -0.54       
                                CEE            -4.31 **      -1.25          0.17       
                                CLS             7.71 ***     -7.65 ***     -1.72       
                                ECO             0.40         -3.29          3.50 *     
                                EDU             5.82 ***     -2.86          2.97 *     
                                ENG            10.28 ***     -8.22 ***     -2.17       
                                HIS             4.81 **     -10.06 ***     -3.22 *     
                                IOE            -0.30         -4.06 *        3.40 *     
                                LIN             2.77         -0.53         -0.99       
                                MEC            -4.82 **      -1.33         -0.51       
                                NRE             0.20         -8.46 ***      3.03 *     
                                NUR             3.37 *       -3.37          5.23 ***   
                                PHI             9.49 ***     -1.62         -0.73       
                                PHY            -1.91         -1.06         -1.30       
                                POL             6.00 ***    -13.21 ***      1.47       
                                PSY             3.16 *       -1.35          0.07       
                                SOC             5.15 ***     -7.23 ***     -0.00       
                                            ───────────────────────────────────────────
                                DF            153.00        153.00        153.00       
                                R2              0.65          0.51          0.39       
                                F statistic    17.49          9.77          6.01       
                              ─────────────────────────────────────────────────────────
                                *** p < 0.001; ** p < 0.01; * p < 0.05.                

Plotting the results

And we can plot the first factor.

mda.biber::heatmap_mda(micusp_mda, n_factor = 1)

Dimension score means by discipline plotted along Factor1.

Interpreting the factors as dimensions

The functional interpretation of factors as dimensions (Brezina pgs. 167-168) is probably the most challenging part of MDA. As analysts, we need to make sense out of why features (whether parts-of-speech, rhetorical categories, or other measures) are grouping together and contributing to the patterns of variation evident in products of the analysis.

That interpretation usually involves giving names to the dimensions based on their constituent structures. In Biber’s original study, he called his first, most explanatory dimension Involved vs. Informational Production. At the positive (Involved) end of the dimension are telephone and face-to-face conversations. At the negative (Information) end are official documents and academic prose.

Features with high positive loadings include private verbs (like think), contractions, and first and second person pronouns. Features with high negative loadings include nouns and propositional phrases. Biber concludes that these patterns reflect the communicative purposes of the registers. Ones that are more interactive and affective vs. others that are more instructive and informative.

Factor 1

private verbs

0.96

that deletion

0.91

contractions

0.9

present tense verbs

0.86

2nd person pronouns

0.86

do as pro-verb

0.82

analytic negation

0.78

demonstrative pronouns

0.76

general emphatics

0.74

1st person pronouns

0.74

pronoun it

0.71

be as main verb

0.71

causative subordination

0.66

discourse particles

0.66

indefinite pronouns

0.62

hedges

0.58

amplifiers

0.56

sentence relatives

0.55

wh- questions

0.52

possibility modals

0.5

non-phrasal coordination

0.48

wh- clauses

0.47

final prepositions

0.43

nouns

-0.8

word length

-0.58

prepositions

-0.54

type/ token ratio

-0.54

attributive adjectives

-0.47

In order to understand how certain features are functioning, it is important to see how they are being used, which we can do effienciently with Key Words in Context (KWIC). Here we take “Confidence High” from the positive end of the dimension and “Academic Writing Moves” from the negative using the kwic() function.

ch <- kwic(micusp_tokens, ds_dict["ConfidenceHigh"])

awm <- kwic(micusp_tokens, ds_dict["AcademicWritingMoves"])

docname

from

to

pre

keyword

post

pattern

BIO.G0.02.1

191

191

sympatry ; do these examples

simply

represent another head on the

ConfidenceHigh

BIO.G0.02.1

401

402

speciation in this genus ,

most likely

under sympatric conditions . The

ConfidenceHigh

BIO.G0.02.1

708

708

that this mechanism is not

very

efficient , and depends on

ConfidenceHigh

BIO.G0.02.1

1143

1143

normal host species respond in

predictable

manners : choosing to mate

ConfidenceHigh

BIO.G0.02.1

1425

1426

explored later ; however ,

it is

important to note here that

ConfidenceHigh

BIO.G0.02.1

1718

1718

of finding a mate that

knows

the same song as you

ConfidenceHigh

docname

from

to

pre

keyword

post

pattern

BIO.G0.02.1

258

260

, yet another possible example

has been described

by science , which may

AcademicWritingMoves

BIO.G0.02.1

594

596

, this type of behavior

has been observed

in the indigobirds of Vidua

AcademicWritingMoves

BIO.G0.02.1

913

914

( Lonchura striata ) .

They conducted

a second experiment in 2000

AcademicWritingMoves

BIO.G0.02.1

939

940

1998 study . Their experiment

was designed

principally to test three hypotheses

AcademicWritingMoves

BIO.G0.02.1

1019

1020

, the Bengalese , finch

were used

. In the cross-foster experiments

AcademicWritingMoves

BIO.G0.02.1

1204

1205

sometime before fledging - this

finding is

also consistent with Payne et

AcademicWritingMoves