Author: Michael Friendly
Date: 2026-08-21

# tidyCat: Tidy Methods For Categorical Data Analysis

#### Michael Friendly

#### 2026-08-21

> Note: This vignette was an initial statement of the problem of “tidy” methods for categorical data analysis. Much of this has now been accomplished by the work of Gavin Klorfine. It is described in the vignette, Steps Toward Tidy Categorical Data Analysis.

## Frequency tables need some Tidy Love ❤️

Tidy methods for quantitative data & models have advanced considerably, but there hasn’t been much development of similar ideas for “categorical data”, by which I mean data that is often compactly represented as \(n\)-way frequency tables, cross classified by one or more discrete factors.

What would it take to implement a tidy framework for such data? These notes are, in effect, a call for participation in developing a `tidyCat` package for this purpose. Other possible names for this: `tidyCDA`, `tidyfreq` but `tidyCat` makes for a nice logo!

    library(MASS)
    library(vcdExtra)

I see three areas that could be developed here:

## Constructing categorical data sets

Current non-tidy data forms and operations, following (Friendly & Meyer, 2016) are described in the vignette Creating and manipulating frequency tables

It seems clear that the most flexible and general form, and one that most closely matches a tidy data frame is case form, because this allows for numeric variables as well. Thus:

> Among these, tidy categorical data should best be represented as a `tibble` in case form. A tibble is convenient for its’ printing.

### Manipulating categorical data sets

The methods `xtabs()`, `table()` and `expand.dft()` described in that vignette allow conversion from one form to another. Tidy equivalents might be:

- `as_table()`, `as_matrix()`, `as_array()` to convert from any form to table/array form

- Similarly, perhaps `as_caseform()`, `as_freqform()` to convert to those. There is already `as.data.frame(table)` to convert to frequency form, and `expand.dft()` converts that to case form

    data("HairEyeColor")
    hec.df <- as.data.frame(HairEyeColor)
    head(hec.df)
    ##    Hair   Eye  Sex Freq
    ## 1 Black Brown Male   32
    ## 2 Brown Brown Male   53
    ## 3   Red Brown Male   10
    ## 4 Blond Brown Male    3
    ## 5 Black  Blue Male   11
    ## 6 Brown  Blue Male   50
    # expand to case form
    expand.dft(hec.df) |> head()
    ##    Hair   Eye  Sex
    ## 1 Black Brown Male
    ## 2 Black Brown Male
    ## 3 Black Brown Male
    ## 4 Black Brown Male
    ## 5 Black Brown Male
    ## 6 Black Brown Male

- `vcd::structable()` (and `stats::ftable()`) produce a ‘flat’ representation of a high-dimensional contingency table constructed by recursive splits (similar to the construction of mosaic displays). One can be constructed from a table or from a data frame with a formula method,

    structable(Titanic)
    ##             Sex      Male     Female
    ##             Survived   No Yes     No Yes
    ## Class Age
    ## 1st   Child             0   5      0   1
    ##       Adult           118  57      4 140
    ## 2nd   Child             0  11      0  13
    ##       Adult           154  14     13  80
    ## 3rd   Child            35  13     17  14
    ##       Adult           387  75     89  76
    ## Crew  Child             0   0      0   0
    ##       Adult           670 192      3  20
    structable(Sex + Class ~ Survived + Age, data = Titanic)
    ##                Sex   Male              Female
    ##                Class  1st 2nd 3rd Crew    1st 2nd 3rd Crew
    ## Survived Age
    ## No       Child          0   0  35    0      0   0  17    0
    ##          Adult        118 154 387  670      4  13  89    3
    ## Yes      Child          5  11  13    0      1  13  14    0
    ##          Adult         57  14  75  192    140  80  76   20

and there are a suite of methods for indexing and selecting parts of an \(n\)-way table.

- The methods in the `plyr` package (now retired) provided a coherent set of tools for a split-apply-combine strategy that works nicely with multidimensional arrays. Perhaps there are some useful ideas for frequency tables that could be resurrected here.

- There is also a role for `purrr` methods and thinking here: \(n\)-way tables as nested lists/arrays? The ideas of mapping over these?

## Manipulating factor levels

Also needed:

- methods for recoding and collapsing the levels of a factor: `forcats::fct_recode()`, `forcats::fct_collapse()`, `forcats::fct_lump_min()` are useful here.

- methods for reordering the levels of a factor, either manually or for some analysis purpose. For example, Data from Glass (1954) gave this 5 x 5 table on the occupations of 3500 British fathers and their sons, where the occupational categories are listed in alphabetic order.

    data(Glass, package="vcdExtra")
    str(Glass)
    ## 'data.frame':    25 obs. of  3 variables:
    ##  $ father: Factor w/ 5 levels "Managerial","Professional",..: 2 2 2 2 2 1 1 1 1 1 ...
    ##  $ son   : Factor w/ 5 levels "Managerial","Professional",..: 2 1 4 3 5 2 1 4 3 5 ...
    ##  $ Freq  : int  50 45 8 18 8 28 174 84 154 55 ...
    (glass.tab <- xtabs(Freq ~ father + son, data=Glass))
    ##               son
    ## father         Managerial Professional Skilled Supervisory Unskilled
    ##   Managerial          174           28     154          84        55
    ##   Professional         45           50      18           8         8
    ##   Skilled             150           14     714         185       447
    ##   Supervisory          78           11     223         110        96
    ##   Unskilled            42            3     320          72       411

This can be reordered manually by indexing, to arrange the categories by status, giving an order `Professional` down to `Unskilled`:

    # reorder by status
    ord <- c(2, 1, 4, 3, 5)
    glass.tab[ord, ord]
    ##               son
    ## father         Professional Managerial Supervisory Skilled Unskilled
    ##   Professional           50         45           8      18         8
    ##   Managerial             28        174          84     154        55
    ##   Supervisory            11         78         110     223        96
    ##   Skilled                14        150         185     714       447
    ##   Unskilled               3         42          72     320       411

A more general method is to permute the row and column categories in the order implied by correspondence analysis dimensions. This is implemented in the `seriation` package using the `CA` method of `seriation::seriate()` and applying `permute()` to the result.

    library(seriation)
    order <- seriate(glass.tab, method = "CA")
    # the permuted row and column labels
    rownames(glass.tab)[order[[1]]]
    ## [1] "Professional" "Managerial"   "Supervisory"  "Skilled"      "Unskilled"

    # reorder rows and columns
    permute(glass.tab, order)
    ##               son
    ## father         Professional Managerial Supervisory Skilled Unskilled
    ##   Professional           50         45           8      18         8
    ##   Managerial             28        174          84     154        55
    ##   Supervisory            11         78         110     223        96
    ##   Skilled                14        150         185     714       447
    ##   Unskilled               3         42          72     320       411

What are tidy ways to do these things?

## Models

The standard analysis of frequency data is in the form of loglinear models fit by `MASS::loglm()` or with more flexible versions fit with `glm()` or `gnm::gnm()`. These are essentially linear models for the log of frequency. In `vcdExtra`, there are several methods for a list of such models, of class `"glmlist"` and `"loglmlist"`, and these should be accommodated in tidy methods.

What is needed are `broom` methods for `loglm` models. The information required is accessible from standard functions, but not in a tidy form.

- `glance.loglm()` – model level statistics. These are given in the output of the `print()` method, and available from the `print()` method. A complication is both LR and Pearson \(\chi^2\) are reported, so these would need to be made to appear in separate columns. There are also related `LRstats()` functions in `vcdExtra`, which report `AIC` and `BIC`.

    hec.indep <- loglm(~Hair+Eye+Sex, data=HairEyeColor)
    hec.indep
    ## Call:
    ## loglm(formula = ~Hair + Eye + Sex, data = HairEyeColor)
    ##
    ## Statistics:
    ##                       X^2 df P(> X^2)
    ## Likelihood Ratio 166.3001 24        0
    ## Pearson          164.9247 24        0
    # extract test statistics
    summary(hec.indep)$tests
    ##                       X^2 df P(> X^2)
    ## Likelihood Ratio 166.3001 24        0
    ## Pearson          164.9247 24        0
    LRstats(hec.indep)
    ## Likelihood summary table:
    ##              AIC   BIC LR Chisq Df Pr(>Chisq)
    ## hec.indep 321.18 332.9    166.3 24  < 2.2e-16 ***
    ## ---
    ## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

- `tidy.loglm()` — coefficient level statistics. These are available from `coef.loglm()`. They would need to assembled into a long format. Standard errors & p-values might be a problem.

    coef(hec.indep)
    ## $`(Intercept)`
    ## [1] 2.646879
    ##
    ## $Hair
    ##       Black       Brown         Red       Blond
    ## -0.17911627  0.79474431 -0.59856762 -0.01706041
    ##
    ## $Eye
    ##      Brown       Blue      Hazel      Green
    ##  0.5296905  0.5067010 -0.3313375 -0.7050540
    ##
    ## $Sex
    ##       Male     Female
    ## -0.0574957  0.0574957

- `augment.loglm()` — should give case level statistics: fitted values, residuals, …

    fitted(hec.indep)
    ## Re-fitting to get fitted values
    ## , , Sex = Male
    ##
    ##        Eye
    ## Hair       Brown     Blue     Hazel     Green
    ##   Black 18.91504 18.48515  7.995903  5.502557
    ##   Brown 50.08982 48.95142 21.174335 14.571585
    ##   Red   12.43489 12.15228  5.256566  3.617421
    ##   Blond 22.24268 21.73717  9.402589  6.470599
    ##
    ## , , Sex = Female
    ##
    ##        Eye
    ## Hair       Brown     Blue     Hazel     Green
    ##   Black 21.22010 20.73782  8.970314  6.173119
    ##   Brown 56.19396 54.91682 23.754719 16.347334
    ##   Red   13.95025 13.63320  5.897151  4.058254
    ##   Blond 24.95326 24.38614 10.548424  7.259131
    residuals(hec.indep)
    ## Re-fitting to get frequencies and fitted values
    ## , , Sex = Male
    ##
    ##        Eye
    ## Hair         Brown       Blue      Hazel      Green
    ##   Black  2.7349421 -1.8843321  0.6818522 -1.1685504
    ##   Brown  0.4073036  0.1493413  0.8080654  0.1116873
    ##   Red   -0.7150915 -0.6371217  0.7233138  1.5738248
    ##   Blond -5.1444108  1.6747424 -1.5778804  0.5796238
    ##
    ## , , Sex = Female
    ##
    ##        Eye
    ## Hair         Brown       Blue      Hazel      Green
    ##   Black  2.9150045 -2.9069608 -1.4476878 -1.9590840
    ##   Brown  1.2726041 -3.0381608  1.0398490 -0.5953638
    ##   Red    0.5361173 -1.9834384  0.4409912  1.3223880
    ##   Blond -5.2211917  6.6539759 -1.9056398  0.2704898

What about `hatvalues`? Not implemented, but shouldn’t be too hard.

    hatvalues(hec.indep)
    ## Error in `UseMethod()`:
    ## ! no applicable method for 'hatvalues' applied to an object of class "loglm"

## Graphical methods

The most common graphical methods are those implemented in `vcd`: `mosaic()` association plots (`assoc()`), …, which rely on `vcd::strucplot()` described in (Meyer, Zeileis, & Hornik, 2006).

Is there a tidy analog that might work with `ggplot2`? The `ggmosaic` package implements basic marimeko-style mosaic plots. They are not very general, in that they cannot do residual-based shading to show the patterns of association.

However, they are based on a productplots package by Hadley, which seems to provide some basic structure for constructing such displays of nested rectangles.

## Some tidy stuff

- The infer package has some interesting stuff for CDA. The vignette Tidy Chi-Squared Tests with infer expresses do a Chisq test of independences as:

    observed_indep_statistic <- gss |>
      specify(college ~ finrela) |>
      hypothesize(null = "independence") |>
      calculate(stat = "Chisq")

You can do simulation tests like this:

    null_dist_sim <- gss |>
      specify(college ~ finrela) |>
      hypothesize(null = "independence") |>
      generate(reps = 1000, type = "permute") |>
      calculate(stat = "Chisq")

## References

Friendly, M., & Meyer, D. (2016). Discrete data analysis with R: Visualization and modeling techniques for categorical and count data. Boca Raton, FL: Chapman & Hall/CRC.
Glass, D. V. (1954). Social mobility in britain. Glencoe, IL: The Free Press.
Meyer, D., Zeileis, A., & Hornik, K. (2006). The strucplot framework: Visualizing multi-way contingency tables with. Journal of Statistical Software, 17(3), 1–48. Retrieved from https://www.jstatsoft.org/v17/i03/