--- title: "Probing nonlinear interactions with interprobe()" author: "Uri Simonsohn" date: "`r Sys.Date()`" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Probing nonlinear interactions with interprobe()} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r setup, include = FALSE} knitr::opts_chunk$set( collapse = TRUE, comment = "#>", fig.width = 10, fig.height = 6, fig.align = "center" ) library(statuser) ``` # Why probe without assuming linearity? Classical interaction models of the form `y ~ x * z` assume that the effect of `x` on `y` changes *linearly* with `z` (the linear interaction effect, or LIE, assumption). That assumption is often false, and when it is false, both the test of the interaction and the Johnson-Neyman / spotlight probes can mislead. `interprobe()` estimates a flexible GAM by default, then draws: - **Simple slopes** (spotlights): predicted `y` at selected values of one predictor across the range of the other - **Johnson-Neyman** curves: the marginal effect of the focal predictor across the moderator **Estimand.** For categorical `x` (2-3 levels), the quantity of interest is the difference in predicted `y` between levels of `x` as a function of `z`. For continuous `x`, it is the derivative $\partial y / \partial x$ at given values of `z` (or the reverse, depending on which variable is on the x-axis). ## A taco example where the linear interaction is wrong The bundled dataset `tacos_data` is a fictitious spice-tolerance study inspired by Montealegre and Simonsohn (2026). Hot salsa is less enjoyable than mild salsa for people with low spice tolerance, and more enjoyable for people with high tolerance—with a nonlinear transition around the middle of the scale. ```{r sim-demo} data(tacos_data) tacos_bin <- tacos_data[!is.na(tacos_data$spicy_bin), ] nrow(tacos_bin) interprobe( x = spicy_bin, z = tolerance, y = enjoyment, data = tacos_bin ) ``` Compare with a linear probe of the same data: the linear model forces a straight Johnson-Neyman line even though the true Hot−Mild effect curves. ```{r sim-linear} interprobe( x = spicy_bin, z = tolerance, y = enjoyment, data = tacos_bin, model = "linear" ) ``` For every argument, defaults, and return-value columns, see `?interprobe`. Run `citation("statuser")` for the papers behind this method. # Data setup (`tacos_data`) ```{r tacos-setup} data(tacos_data) tacos_bin <- tacos_data[!is.na(tacos_data$spicy_bin), ] ``` Columns: `spicy` (1–7), `salsa` (Mild/Medium/Hot), `spicy_bin` (Mild/Hot when `spicy` is 1 or 7), `tolerance`, `enjoyment`, and `age` (a nuisance covariate). # Case A: binary x × continuous moderator Mild vs Hot salsa (`spicy_bin`) by spice `tolerance`, predicting `enjoyment`. ```{r case-a} res_a <- interprobe( x = spicy_bin, z = tolerance, y = enjoyment, data = tacos_bin ) ``` **How to read the figure.** - The **left panel** (simple slopes) shows predicted enjoyment as a function of tolerance, separately for Mild and Hot salsa. Darker / thicker lines mark regions with more data (`n.max` controls when darkness saturates). - The **right panel** (Johnson-Neyman) shows the Hot−Mild effect across tolerance, with a confidence band. Where the band excludes zero, the effect is significant. - The **histogram** under each panel shows the distribution of the moderator (common support). Unless `quiet = TRUE`, the console also prints an HC3 linear interaction test and a GAM interaction F test for the internal GAM fit, plus Johnson-Neyman regions of significance for categorical focal predictors. # Case B: 3-level categorical x Salsa type (`salsa`: Mild / Medium / Hot) by tolerance. ```{r case-b} res_b <- interprobe( x = salsa, z = tolerance, y = enjoyment, data = tacos_data ) ``` With 2-3 unique values of `x`, `interprobe()` treats the focal predictor as **categorical**: one curve per level, spotlights unused, and Johnson-Neyman regions of significance printed to the console (pairwise contrasts across the moderator). # Case C: continuous × continuous Spiciness rating (`spicy`, 1–7) by tolerance. ```{r case-c} res_c <- interprobe( x = spicy, z = tolerance, y = enjoyment, data = tacos_data ) ``` For continuous (or discrete with more than 3 values) predictors, three **spotlights** are used. The default is the 15th, 50th, and 85th percentiles of the off-axis variable. You can set your own: ```{r case-c-spotlights} interprobe( x = spicy, z = tolerance, y = enjoyment, data = tacos_data, spotlights = c(2, 4, 6), spotlight.labels = c("Low tolerance (2)", "Mid (4)", "High (6)") ) ``` By default the moderator is on the x-axis (`moderator.on.x.axis = TRUE`). Set it to `FALSE` to put the focal predictor on the x-axis (ignored when `x` has 3 or fewer unique values)—useful here so spiciness is on the x-axis and each curve is a tolerance spotlight, matching the paper’s continuous-focal figure. ```{r case-c-axis} interprobe( x = spicy, z = tolerance, y = enjoyment, data = tacos_data, moderator.on.x.axis = FALSE ) ``` # Covariates To include covariates, fit the model yourself and pass it as `model`. Other predictors are held at their means on the prediction grid. ```{r covariates} lm1 <- lm(enjoyment ~ spicy_bin * tolerance + age, data = tacos_bin) interprobe(model = lm1, x = spicy_bin, z = tolerance) ``` You can also pass `lm2()`, `glm()`, or `mgcv::gam()` fits. # GAM vs model = "linear" Same data, two engines: ```{r gam-vs-linear, fig.show = "hold"} interprobe(x = spicy_bin, z = tolerance, y = enjoyment, data = tacos_bin) interprobe( x = spicy_bin, z = tolerance, y = enjoyment, data = tacos_bin, model = "linear" ) ``` Use the GAM default when you want robustness to nonlinearities. Use `model = "linear"` when you specifically want the classical linear interaction probed (or when a GAM will not converge; see Troubleshooting). # Working with the returned list `interprobe()` returns (invisibly) a list. Capture it to reuse the numeric results without redrawing. ```{r returned-list} res <- interprobe( x = spicy_bin, z = tolerance, y = enjoyment, data = tacos_bin, draw = "none" ) names(res) head(res$johnson.neyman) head(res$simple.slopes) ``` When a model is estimated inside `interprobe()`, `res$gam_results` holds that fit (an `lm2` object if you used `model = "linear"`). For a binary focal predictor under the GAM engine, `res$gam_results_testing` and `res$lm2_results` support the printed interaction tests. ```{r inspect-model} summary(res$gam_results) ``` # Saving and customizing figures Brief options (full list in `?interprobe`): ```{r customize, eval = FALSE} interprobe( x = spicy_bin, z = tolerance, y = enjoyment, data = tacos_bin, draw = "jn", # or "simple slopes", "both", "none" cols = c("black", "steelblue"), save.as = "spicy_tolerance_jn.png", xlim = c(1, 7) ) ``` # Troubleshooting - **GAM estimation fails.** Predictors with few unique values often need a smaller basis: try `k = 3` or `k = 2`. - **Only one unique value of `x` or `z`.** `interprobe()` stops; there is nothing to probe. - **`cols` length.** Binary focal predictors need length 2 (or 3); continuous / discrete spotlights and 3-level categorical need length 3. - **`spotlights` length.** Must be length 3 when supplied. - **Both `data` and `model`.** Not allowed; pick one input path. # References Simonsohn, U. (2024). Interacting with curves: How to validly test and probe interactions in the real (nonlinear) world. *Advances in Methods and Practices in Psychological Science*, 7(1), Article 25152459231207787. https://doi.org/10.1177/25152459231207787 Montealegre, D., & Simonsohn, U. (2026). *Johnson Neyman 2.0* (working paper). Run `citation("statuser")` for ready-to-use BibTeX / text citations.