Tidy collostructions

tl ; dr

In this post I look at the family of collexeme analysis methods originated by Gries and Stefanowitsch. Since they use a lot of Base R, and love using vectors, there is a hurdle that needs to be conquered if you are used to the rectangles in tidy data. I first give an overview of what the method tries to do, and then at the end show the hurdle in detail, followed by the steps necessary to enable the computation of the desired variables and statistical tests (association measures). Basically, you need to convert from pure tidy data to a tidy contingency format.

Introduction

So in order to finalize my database on Chinese ideophones, creatively entitled CHIDEOD, I decided to work through Stefan Gries’s Quantitative corpus linguistics with R (2016; 2nd edition; companion website here).
That together with Natalia Levshina’s How to do linguistics with R (2015; 1st edition; companion website here), which I worked through last June, has given me a lot of inspiration to tackle a number issues I have been struggling with, or at least thinking about without really knowing how to tackle them — I guess that counts as struggling with. By the way, it is locally kind of confirmed currently that Levshina will be a keynote speaker at our CLDC conference in May. So you can already prepare those abstracts if you wish to attempt to present here in Taipei.

Anyway, one of the more intriguing family of methods to investigate the relation between constructions and words that fill the slots is the family of collexeme analysis.

What is collexeme analysis?

As Gries (2019) discusses, the methodology of collexeme analysis is an extension of the notions of a) collocation (which words really belong together, e.g. watch TV and watch a movie will have a stronger collocational bond than watch a powerpoint presentation, although the latter has become a weekly activity), and b) colligation (what constructions belong together, e.g. watch will typically be followed by a noun, although verb phrases like watch him play also occur).

So the basis of this method is a contingency table (or cross-table; or kruistabel if you love Dutch; lièlián biǎo 列聯表 in Chinese (apparently)). Basically, something like this, that should look somewhat familiar:

Element 2 Not element 2 / other elements Sum
Element 1 a b a + b
Not element 1 / other elements c d c + d
Sum a + c b + d a + b + c + d

These letters (a, b, c, d) play an important role in colloxeme analyses. So stay tuned for that.

Approach 1: Collexeme analysis

So what Stefanowitsch & Gries (2003) wanted to investigate was the [N waiting to happen] construction, e.g. an accident waiting to happen, a disaster waiting to happen etc. In this collexeme analysis you are quantifying to what degree the words found in a corpus occur in that construction: are they attracted or repulsed, and by how much?

In this scenario, element 1 is e.g. an accident, and element 2 the construction waiting to happen.

waiting to happen not waiting to happen Sum
an accident a b a + b
not an accident c d c + d
Sum a + c b + d a + b + c + d

Approach 2: Distinctive collexeme analysis

A year later, Gries & Stefanowitsch (2004) extended the methodology to quantify to what degree the words prefer to appear in one of two constructions. For example, the ditransitive give him a call vs. the prepositional give a call to him. Here the table changes into:

ditransitive prepositional Sum
give a b a + b
not give c d c + d
Sum a + c b + d a + b + c + d

Approach 3: Co-varying collexeme analysis

The third seminal paper was published in the same year (Gries & Stefanowitsch 2004; ISBN: 9781575864648) sought to quantify the attraction / repulsion between two different slots in a construction, e.g. trick … into buying, force … into accepting etc. For this, the table is adapted to:

accepting not accepting Sum
force a b a + b
not force c d c + d
Sum a + c b + d a + b + c + d

Okay, I get that table stuff, what next?

Let’s say you were able to get the frequencies from a corpus – I should probably mention that, *cough* *cough* not everybody agrees with this kind of contingency tables, the overview paper by Gries (2019) has plenty of interesting references and exciting rebuttals – and you have this table, possibly for lots of elements and/or constructions.

We’ll keep an example here that I calculated with Gries’s (2016) book mentioned above. One of the case studies looks at verbs that co-occur with the modal verb must, e.g. must accept, must agree, must confess, vs. how much these verbs occur with other modal verbs, e.g should accept, should agree etc. So in this case, element 1 was a verb (confess) and element 2 was must; not-element-1 were all the other verbs, and not-element-2 were all the other modal verbs.

The respective table from an abstract level looks like this:

must not must Sum
confess a b a + b
not confess c d c + d
Sum a + c b + d a + b + c + d

I got these frequencies from the BNC (as Gries shows in his book, but I used my tidyverse skillsTM to get them, so if they are slightly off, then it was because of not following his script completely):

must not must Sum
confess 11 1 12
not confess 1990 62114 64104
Sum 2001 62115 64116

Or in a Base R table:

a <- 11
b <- 1
c <- 1990
d <- 62114

confess <- c(a, b)
notconfess <- c(c, d)

must.table <- rbind(confess, notconfess) 
colnames(must.table) <- c("must", "notmust")
must.table
##            must notmust
## confess      11       1
## notconfess 1990   62114

What do you do now?

Now it’s time for letter math!

The association measure (read: statistical test) that Gries & Stefanowitsch, as well as others, have used most is the Fisher Yates Exact test, and more precisely the negative \(log10\) of its \(p\)-value. Underlying this (see the link in this paragraph) are calculations using those letter cells (a,b,c,d). Luckily we don’t need to do that manually because R has a function for this – fischer.test():

fisher.test(must.table)
## 
##  Fisher's Exact Test for Count Data
## 
## data:  must.table
## p-value = 3.106e-16
## alternative hypothesis: true odds ratio is not equal to 1
## 95 percent confidence interval:
##     49.90447 13241.49567
## sample estimates:
## odds ratio 
##   344.6593

So as you can see the pvalue is 3.105600710^{-16}. If we take the negative \(log10\) of this, it becomes 15.5078544, which gives us the result we expected.

An easier way of computing the Fisher Yates Exact test of this table is by using the letter math and the pv.Fischer.collostr function provided by Levshina’s Rling package:

Rling::pv.Fisher.collostr(a, b, c, d)
## [1] 3.105601e-16

Other tests that have been proposed are so-called Reliance and Attraction (cf. Schmid & Küchenhoff 2013 a.o.). Reliance is the relative frequency of a verb (confess) with must with regard to all uses of the given verb; Attraction is the relative frequency of a verb (confess) with must based on all usages with must.

\[ Reliance = \frac{a}{a+b}\]

\[ Attraction = \frac{a}{a+c} \]

attraction <- a / (a+c) * 100
reliance <- a / (a+b) * 100

The Attraction of confess and must is 0.5497251 and the Reliance is 91.6666667. This high Reliance means that whenever confess occurs in the corpus after a modal it relies on must to occur. Its Attraction, however, has a much lower value: must does not necessarily occur with confess, in fact, it occurs with lots of other verbs as well!

The third group of tests is actually correlated to Attraction and Reliance: respectively \(\Delta P_{word \to construction}\) and \(\Delta P_{construction \to word}\) (cf. Ellis & Ferreira-Junior a.o.). They are also known as tests of cue validity and are caluclated as follows:

\[\Delta P_{word \to construction} = cue_{construction} = \frac{a}{a + c} - \frac{b}{b + d}\]

\[\Delta P_{construction \to word} = cue_{verb} = \frac{a}{a + b} - \frac{c}{c + d}\]

dP.cueCx <- a/(a + c) - b/(b + d)
dP.cueVerb <- a/(a + b) - c/(c + d)

So the \(\Delta P_{word \to construction}\) of must confess is 0.0054812 and the \(\Delta P_{construction \to word}\) of must confess is 0.8856234, so these numbers look a lot like those of Attraction and Reliance.

Anyway, I think you get the drift: if you have the contingency table, you choose an association measure (there are more than these three sets) and analyze the results.

Tidy collostructions

So if that’s so clear, why am I writing this post? For me, the main difficulty with the approach is that Gries and Levshina love writing things in Base R (Gries even more than Levshina). But to someone who really started to appreciate R after the tidyverse became more available, there is this dissonance with the way they go about things.

One of the biggest difference is the obsessive-compulsion of tidyverse to think in rectangles, a.k.a. dataframes or tibbles, rather than the vector( letter)s that Gries and Levshina love using, especially in their letter mathematics.

As a consequence of this “rectangling” and tidy format (long and skinny), rather than contingency format (2 x 2), it is challenging to compute even basic chisquare.tests.

So in Base R, a chisquare is easy to compute, but then hard to continue working with, because this thick text block is given and you can’t really access the contents (but there are solutions, like broom::tidy):

chisq.test(must.table)
## Warning in chisq.test(must.table): Chi-squared approximation may be
## incorrect
## 
##  Pearson's Chi-squared test with Yates' continuity correction
## 
## data:  must.table
## X-squared = 282.63, df = 1, p-value < 2.2e-16

In a tidy format, you typically have to jump through a lot of hoops to either get to use the same function or use one of the newer alternative functions:

library(tidyverse)
must.tibble <- tribble(
  ~ verb, ~ must, ~notmust,
  "confess", 11, 1,
  "notconfess", 1990, 62114
)
must.tibble %>% kable()
verb must notmust
confess 11 1
notconfess 1990 62114
# this function will give you bad output
# so you can't just simply do this
chisq.test(must.tibble$must, must.tibble$notmust)
## Warning in chisq.test(must.tibble$must, must.tibble$notmust): Chi-squared
## approximation may be incorrect
## 
##  Pearson's Chi-squared test with Yates' continuity correction
## 
## data:  must.tibble$must and must.tibble$notmust
## X-squared = 0, df = 1, p-value = 1

Hoop 1: make it really tidy

must.tibble %>%
  pivot_longer(cols = c(must, notmust),
               names_to = "modal",
               values_to = "n") %>% # now it is really tidy
  kable()
verb modal n
confess must 11
confess notmust 1
notconfess must 1990
notconfess notmust 62114

Hoop 2: uncount

must.tibble %>%
  pivot_longer(cols = c(must, notmust),
               names_to = "modal",
               values_to = "n") %>% # now it is really tidy
  uncount(n)  # uncount
## # A tibble: 64,116 x 2
##    verb    modal
##    <chr>   <chr>
##  1 confess must 
##  2 confess must 
##  3 confess must 
##  4 confess must 
##  5 confess must 
##  6 confess must 
##  7 confess must 
##  8 confess must 
##  9 confess must 
## 10 confess must 
## # … with 64,106 more rows

Hoop 3: convert to table

must.tibble %>%
  pivot_longer(cols = c(must, notmust),
               names_to = "modal",
               values_to = "n") %>% # now it is really tidy
  uncount(n)  %>% # uncount
  table() # turn to Base R table
##             modal
## verb          must notmust
##   confess       11       1
##   notconfess  1990   62114

Hoop 4: chisquare

must.tibble %>%
  pivot_longer(cols = c(must, notmust),
               names_to = "modal",
               values_to = "n") %>% # now it is really tidy
  uncount(n)  %>% # uncount
  table() %>% # turn to Base R table
  chisq.test() 
## Warning in chisq.test(.): Chi-squared approximation may be incorrect
## 
##  Pearson's Chi-squared test with Yates' continuity correction
## 
## data:  .
## X-squared = 282.63, df = 1, p-value < 2.2e-16

Hoop 5: tidy with broom

must.tibble %>%
  pivot_longer(cols = c(must, notmust),
               names_to = "modal",
               values_to = "n") %>% # now it is really tidy
  uncount(n)  %>% # uncount
  table() %>% # turn to Base R table
  chisq.test()%>%
  broom::tidy() %>%
  kable()
## Warning in chisq.test(.): Chi-squared approximation may be incorrect
statistic p.value parameter method
282.6321 0 1 Pearson’s Chi-squared test with Yates’ continuity correction

I know there are some tidyverse-friendly functions like infer::chisq_test, but it seems to lack arguments like expected values (chisq.test()$exp). So this awkward hoop jumping is annoying but gets the job done (for now?).

From tidy to contingency

Anyway, while for most summary statistics a tidy format (see hoop 1) is the easiest to work with, I don’t think it’s very intuitive for the association measures paradigm.

So in this working example of must + V_inf_ construction what I did was get all the occurrences of all modal verbs + verbs in the infinitive from the BNC corpus. The second step I did was add a column with dplyr::case_when to identify if a modal verb was must or another modal verb.

Let’s look at this data shall we:

df.must
## # A tibble: 64,116 x 3
##    modal  verb      mod.type
##    <chr>  <chr>     <chr>   
##  1 can    find      OTHER   
##  2 should stop      OTHER   
##  3 should recognise OTHER   
##  4 will   cost      OTHER   
##  5 might  think     OTHER   
##  6 can    sink      OTHER   
##  7 can    change    OTHER   
##  8 must   help      MUST    
##  9 ll     help      OTHER   
## 10 ll     take      OTHER   
## # … with 64,106 more rows

As you can see there are 64116 rows, and the table is just a count away form being tidy.

tidy.df.must <- df.must %>%
  count(mod.type, verb, sort = TRUE)
tidy.df.must
## # A tibble: 2,522 x 3
##    mod.type verb      n
##    <chr>    <chr> <int>
##  1 OTHER    get    3750
##  2 OTHER    go     3421
##  3 OTHER    see    2409
##  4 OTHER    take   2056
##  5 OTHER    like   1778
##  6 OTHER    say    1647
##  7 OTHER    come   1460
##  8 OTHER    make   1383
##  9 OTHER    give   1245
## 10 OTHER    put    1162
## # … with 2,512 more rows

Much ‘prettier’, but what next? This is actually the mental step I struggled the most with, this dissonance between tidy and contingency. However, since we coded mod.type with a binary value: “OTHER” or “MUST”, it is actually trivial to dplyr::spread or dplyr::pivot_wider them to a “tidy contingency table”:

spread.tidy.df.must <- tidy.df.must %>%
  pivot_wider(values_from = n,
              names_from = mod.type) 
spread.tidy.df.must
## # A tibble: 2,113 x 3
##    verb  OTHER  MUST
##    <chr> <int> <int>
##  1 get    3750   102
##  2 go     3421    94
##  3 see    2409    14
##  4 take   2056    63
##  5 like   1778     3
##  6 say    1647   105
##  7 come   1460    41
##  8 make   1383    62
##  9 give   1245    22
## 10 put    1162    18
## # … with 2,103 more rows

Hey, this looks a lot like those schematic tables we had at the beginning! But now the real challenge is to turn these numbers into the letters a, b, c, and d, so we can perform our letter math.

After changing all numeric values we have to doubles (instead of integers) and changing all NAs to 0, we can get the letters by following simple arithmetic from our original schematic table, e.g. if we know a and a+c, then c = a+c - a etc.

Element 2 Not element 2 / other elements Sum
Element 1 a b a + b
Not element 1 / other elements c d c + d
Sum a + c b + d a + b + c + d
spread.tidy.df.must %>%
  mutate_if(is.numeric, ~ as.double(.x)) %>% # we'll want doubles instead of integers
  mutate_if(is.numeric, ~ replace_na(.x, 0)) %>% # NAs should be 0
  
  mutate(a = MUST,
       ac = sum(MUST),
       c = ac - a,
       ab = MUST + OTHER,
       b = ab - a,
       abcd = sum(MUST, OTHER),
       d = abcd - a - b -c,
       aExp = (a + b)*(a + c)/(a + b + c + d)) -> abcd.must
abcd.must
## # A tibble: 2,113 x 11
##    verb  OTHER  MUST     a    ac     c    ab     b  abcd     d  aExp
##    <chr> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>
##  1 get    3750   102   102  2001  1899  3852  3750 64116 58365 120. 
##  2 go     3421    94    94  2001  1907  3515  3421 64116 58694 110. 
##  3 see    2409    14    14  2001  1987  2423  2409 64116 59706  75.6
##  4 take   2056    63    63  2001  1938  2119  2056 64116 60059  66.1
##  5 like   1778     3     3  2001  1998  1781  1778 64116 60337  55.6
##  6 say    1647   105   105  2001  1896  1752  1647 64116 60468  54.7
##  7 come   1460    41    41  2001  1960  1501  1460 64116 60655  46.8
##  8 make   1383    62    62  2001  1939  1445  1383 64116 60732  45.1
##  9 give   1245    22    22  2001  1979  1267  1245 64116 60870  39.5
## 10 put    1162    18    18  2001  1983  1180  1162 64116 60953  36.8
## # … with 2,103 more rows

So now we can easily perform all of the association measures we want, and rank them accordingly.

Fischer Yates Exact

fye <- abcd.must %>%
  mutate(fye = Rling::pv.Fisher.collostr(a, b, c, d)) %>%
  #filter(verb == "confess") %>%
  mutate(negfye = case_when(a < aExp ~ format(round(log10(fye)), nsmall = 2),
                            TRUE ~ format(round(- log10(fye)), nsmall = 2)),
         negfye = as.double(negfye)) %>% #I did some rounding
  arrange(desc(negfye)) %>%
  select(verb, OTHER, MUST, fye, negfye)

fye %>% top_n(20) %>% kable()
verb OTHER MUST fye negfye
admit 11 159 0.0000000 225
confess 1 11 0.0000000 16
say 1647 105 0.0000000 9
realise 22 11 0.0000000 9
recognise 46 13 0.0000000 8
know 432 38 0.0000001 7
remember 324 33 0.0000000 7
rank 3 6 0.0000001 7
decide 120 17 0.0000014 6
pay 354 30 0.0000052 5
wait 139 16 0.0000307 5
ensure 53 10 0.0000243 5
inform 5 5 0.0000065 5
stress 5 5 0.0000065 5
apologise 1 3 0.0001186 4
obey 2 3 0.0002895 4
look 560 33 0.0017845 3
feel 223 18 0.0006318 3
act 47 7 0.0014017 3
assume 24 5 0.0018726 3
reflect 24 5 0.0018726 3
mention 23 5 0.0015902 3
emerge 13 4 0.0016252 3
own 9 4 0.0005395 3
emphasise 7 3 0.0030892 3
register 7 3 0.0030892 3
balance 6 3 0.0022137 3
realize 6 3 0.0022137 3
respect 3 3 0.0005656 3
comply 1 2 0.0028599 3
exhibit 1 2 0.0028599 3
export 0 2 0.0009735 3
outperform 0 2 0.0009735 3
fye %>% arrange(negfye) %>% top_n(-20) %>% kable()
verb OTHER MUST fye negfye
like 1778 3 0.0000000 -20
see 2409 14 0.0000000 -18
hear 428 0 0.0000021 -6
help 820 7 0.0000185 -5
want 401 0 0.0000074 -5
tell 985 13 0.0003130 -4
give 1245 22 0.0031496 -3
put 1162 18 0.0006828 -3
need 461 4 0.0018961 -3
lead 321 1 0.0009838 -3
imagine 290 1 0.0019299 -3
call 268 0 0.0003216 -3
cause 211 0 0.0022792 -3
buy 402 4 0.0090712 -2
play 340 4 0.0291563 -2
happen 335 2 0.0039267 -2
afford 205 0 0.0035887 -2
receive 205 1 0.0242516 -2
pick 203 1 0.0239273 -2
appear 187 0 0.0049951 -2
benefit 147 0 0.0160195 -2

Attraction, Reliance

abcd.must %>%
  mutate(attraction = a / (a+c) * 100,
         reliance = a / (a+b) * 100) %>%
  arrange(desc(attraction)) %>%
  top_n(20) %>%
  select(verb, OTHER, MUST, attraction, reliance) %>%
  kable()
## Selecting by reliance
verb OTHER MUST attraction reliance
export 0 2 0.099950 100
outperform 0 2 0.099950 100
acquaint 0 1 0.049975 100
capitalise 0 1 0.049975 100
capitulate 0 1 0.049975 100
class 0 1 0.049975 100
combat 0 1 0.049975 100
comfort 0 1 0.049975 100
discard 0 1 0.049975 100
dissociate 0 1 0.049975 100
dread 0 1 0.049975 100
enclose 0 1 0.049975 100
graft 0 1 0.049975 100
interrogate 0 1 0.049975 100
itch 0 1 0.049975 100
nurture 0 1 0.049975 100
plagiarise 0 1 0.049975 100
protrude 0 1 0.049975 100
rediscover 0 1 0.049975 100
redouble 0 1 0.049975 100
shoulder 0 1 0.049975 100
shrive 0 1 0.049975 100
stipulate 0 1 0.049975 100

Delta P

abcd.must %>%
  mutate(dP.cueCx = a/(a + c) - b/(b + d),
         dP.cueVerb = a/(a + b) - c/(c + d)) %>%
  arrange(desc(dP.cueCx)) %>%
  select(verb,  OTHER, MUST, dP.cueCx, dP.cueVerb) %>%
  top_n(20) %>%
  kable()
verb OTHER MUST dP.cueCx dP.cueVerb
export 0 2 0.0009995 0.9688212
outperform 0 2 0.0009995 0.9688212
acquaint 0 1 0.0004998 0.9688061
capitalise 0 1 0.0004998 0.9688061
capitulate 0 1 0.0004998 0.9688061
class 0 1 0.0004998 0.9688061
combat 0 1 0.0004998 0.9688061
comfort 0 1 0.0004998 0.9688061
discard 0 1 0.0004998 0.9688061
dissociate 0 1 0.0004998 0.9688061
dread 0 1 0.0004998 0.9688061
enclose 0 1 0.0004998 0.9688061
graft 0 1 0.0004998 0.9688061
interrogate 0 1 0.0004998 0.9688061
itch 0 1 0.0004998 0.9688061
nurture 0 1 0.0004998 0.9688061
plagiarise 0 1 0.0004998 0.9688061
protrude 0 1 0.0004998 0.9688061
rediscover 0 1 0.0004998 0.9688061
redouble 0 1 0.0004998 0.9688061
shoulder 0 1 0.0004998 0.9688061
shrive 0 1 0.0004998 0.9688061
stipulate 0 1 0.0004998 0.9688061

Conclusion

It is perfectly possible to perform association measures starting with a tidy dataframe. First you need to spread out with a binary variable, to make it a tidy contingency table. Then you can identify a, b, c, and d. Next you can chooose your preferred statistical test, rank verbs, and try to interpret the findings.

I should probably also mention that Gries is has been advocating to not just use one association measure, but three or more. Using just two, you can plot like Attraction and Reliance.

abcd.must %>%
  mutate(attraction = a / (a+c) * 100,
         reliance = a / (a+b) * 100) %>%
  arrange(desc(attraction)) %>%
  select(verb, OTHER, MUST, attraction, reliance) %>%
  ggplot(aes(x = attraction, 
             y = reliance)) +
   geom_point() +
  gghighlight::gghighlight(attraction > 2 | 50 < reliance & reliance < 100,
                           label_key = verb) +
  theme_minimal()

Avatar
Thomas Van Hoey
PhD Candidate in Linguistics

My research interests include ideophones, (Premodern) Chinese, historical linguistics, Cognitive Linguistics, and lexical semantics.

Related

comments powered by Disqus