OSCR

A cortical semantic space integrating fractions and integers

Code ↔ Paper

The paper beside its authors' code: matches between them have not been computed for this paper yet.

Paper

Loaded from Europe PMC by your browser; OSCR does not store it: doi.org · Europe PMC

The paper is loaded when this pane is shown.

The authors' code

R Markdown · 1,122 lines · 56 KB · no license

  1. ---
  2. title: Analyses ligne numérique CSEN - Expérience 1
  3. author:
  4. - Maxime Cauté
  5. lang: fr
  6. output: rmdformats::readthedown
  7. fig-dpi: 160
  8. ---
  9. ```{r settings, echo = FALSE, message = FALSE}
  10. knitr::opts_chunk$set(echo = FALSE, warning = FALSE, message = FALSE)
  11. # Package manager
  12. if(!require("pacman")) install.packages("pacman")
  13. # GGplot-friendly library for IRT.
  14. if(!require("ggmirt")) devtools::install_github("masurp/ggmirt")
  15. pacman::p_load(tidyr, dplyr, tibble, magrittr, # Tidy scripts
  16. apa, # Report stats in APA format
  17. mirt, ggmirt, # IRT analysis
  18. egg, # Very neat ggarrange
  19. ggplot2,
  20. devtools,
  21. ggprism, grid, ggpubr, # Plot legends
  22. purrr,
  23. officer, rvg, # Outputing pptx
  24. pandoc, # HTML output
  25. stringr, #String manipulations
  26. #cooccur,
  27. reshape, # Matrix manipulation and stuff
  28. install = T)
  29. ```
  30. ```{r}
  31. # All helpers in a dedicated file
  32. source("all_helpers.R")
  33. # Parameters for printing
  34. PT_TO_PIXEL = 16/12
  35. FAMILY = "NimbusSan"
  36. # Add a theme that specifies the size
  37. theme_text = function(size = 16, title.size = 18, family = FAMILY, scaling_factor = 1){
  38. theme(text = element_text(family = family, size = size * scaling_factor),
  39. axis.text = element_text(family = family, size = size * scaling_factor),
  40. legend.text = element_text(family = family, size = size * scaling_factor),
  41. axis.title = element_text(family = family, size = title.size * scaling_factor),
  42. legend.title = element_text(family = family, size = title.size * scaling_factor),
  43. strip.text = element_text(family = family, size = title.size * scaling_factor),
  44. plot.subtitle = element_text(family = family, size = title.size * scaling_factor),
  45. plot.title = element_text(family = family, size = title.size * scaling_factor)) %>%
  46. return
  47. }
  48. # Use PT_TO_PIXEL to save the text as the correct size in pt in svg (svg saves raw text.size as px)
  49. # If you want to export as knit, you may want to change this.
  50. theme_set(theme_get() %+replace% theme_text(scaling_factor = PT_TO_PIXEL))
  51. ```
  52. # Data loading
  53. Load the data, only drop the column index.
  54. ```{r dataLoading}
  55. d = read.csv("./data/cleaned_all_years.csv") %>% select(-X)
  56. ```
  57. ### Performance on integers and decimals
  58. Only keep data relevant to the experiment: data from 6th graders with no tutorial.
  59. ```{r dataFiltering}
  60. d %<>%
  61. # Remove the tutorial trial
  62. filter(!is.tutorial) %>%
  63. # Only keep 6th graders for Exp. 1
  64. filter(class == "6ème") %>%
  65. # Only keep data from the second line, on integers (6), decimals (7), and fractions (9)
  66. filter(stimtype %in% c(6, 7, 9))
  67. ```
  68. Check that we have what we expect: average error rates with examples for each type of stimulus
  69. ```{r performance on integers and decimals}
  70. d %>%
  71. # Compute the error rate for each type of stimulus, for each participant. Take the first stimulus as an example.
  72. group_by(subj, stimtype) %>% summarise(
  73. error_rate = mean(!is.correct), sample_stimulus = stimulus %>% first, .groups = "drop") %>%
  74. # Average across targets
  75. group_by(stimtype) %>% summarise(
  76. error_rate.se = se(error_rate), error_rate = mean(error_rate),
  77. sample_stimulus = sample_stimulus %>% first) %>%
  78. # Round error rates at 3 decimals, and error rate at 4. Display both as %.
  79. mutate(error_rate = error_rate %>% (scales::percent_format(accuracy = 0.1)),
  80. error_rate.se = error_rate.se %>% (scales::percent_format(accuracy = 0.01))) %>%
  81. # Only display relevent informations
  82. select(stimtype, sample_stimulus, error_rate, error_rate.se)
  83. ```
  84. As we can see, performance on integers is correct, somewhat so for decimals. Fractions really stand out!
  85. ### Fractions
  86. ```{r fractionFilter}
  87. # Now that we looked at error rates across all targets, drop everything but fractions.
  88. d %<>% filter(stimtype == 9) %>% select(-stimtype)
  89. ```
  90. ```{r dataFormattingHelpers}
  91. # Create pretty names for our different categories of fractions
  92. fraction_categories = c("Equivalent to 1/2", "Integer result", "n / 1", "Tenths")
  93. # Create a function that categorizes fractions...
  94. categorize_fractions = function(numerator, denominator){
  95. factor(
  96. # ... if the denominator is ten.
  97. if_else(denominator == 10, fraction_categories[4],
  98. # ... if the denominator is 1
  99. if_else(denominator == 1, fraction_categories[3],
  100. # ... if the fraction is equivalent to 1/2
  101. if_else(round(numerator / denominator, 1) == 0.5, fraction_categories[1],
  102. # ... or else, then the fraction is equivalent to an integer
  103. fraction_categories[2]))),
  104. # Make this a factor with the desired order.
  105. levels = fraction_categories) %>%
  106. return
  107. }
  108. # Define a helper function that counts responses with an entry for all possible responses,
  109. # including those that were never answered
  110. # This is not done by count().
  111. count_possible_responses = function(d, ...){
  112. d %>%
  113. # Count responses per stimulus and position and whatever additional argument is passed.
  114. count(stimulus, actual_response, ...) %>%
  115. # Right join with...
  116. right_join(
  117. # ... all the target stimuli (and whatever is also grouping)...
  118. d %>% select(stimulus, ...) %>% unique %>%
  119. # ... and all 51 positions on the number line
  120. merge(tibble(position = seq(0, 5, 0.1) %>% round(1))) %>%
  121. # And now order that to make it tidy
  122. arrange(stimulus),
  123. # Join by the specified columns, mapping actual response positions to the possible positions
  124. by = d %>% select(stimulus, ...) %>% colnames %>% c("actual_response" = "position")
  125. ) %>%
  126. # If some stimulus was added with the right join, then it has no responses.
  127. mutate(n = n %>% replace_na(0)) %>%
  128. # Make everything easier to read.
  129. arrange(stimulus, actual_response) %>%
  130. return
  131. }
  132. # This loads a function that can compute_fractions_predictions, which is shared across analysis files.
  133. source("src/fractions_predictions.R")
  134. # Compute the predictions for our actual stimuli.
  135. fractions_predictions.d = d %>%
  136. # Select the relevant stimuli and make them unique
  137. select(stimulus) %>% unique %>%
  138. # Compute the predictions.
  139. compute_fractions_predictions
  140. ```
  141. ```{r dataFormatting}
  142. # Format the data to help later computations
  143. d %<>%
  144. # Get the exact actual response to avoid floating-point errors.
  145. mutate(actual_response = actual.response %>% round(1), .keep = "unused") %>%
  146. # Extract the components of the stimuli
  147. mutate(numerator = stimulus %>% str_split_i(" / ", 1) %>% as.numeric,
  148. denominator = stimulus %>% str_split_i(" / ", 1) %>% as.numeric) %>%
  149. # Compute the fraction category from the components
  150. mutate(category = categorize_fractions(numerator, denominator)) %>%
  151. # Adopt a snake_case convention.
  152. dplyr::rename(is_correct = is.correct)
  153. ```
  154. # Demographics
  155. Number of girls
  156. ```{r}
  157. # Read demographics data
  158. demographics.d = read.csv("./demographics/demographics_all_years.csv")
  159. # Compute the number of each sex.
  160. demographics.d %>%
  161. # Only keep the participants whose data we care about
  162. filter(subj %in% d$subj) %>%
  163. # Count by sex
  164. count(sex)
  165. ```
  166. # Poor and varied performance
  167. ## Global error rate
  168. How many errors do children do on average?
  169. ```{r global error rate}
  170. # Compute the error rate averaged across subjects
  171. d %>%
  172. # Compute each subject's error rate
  173. group_by(subj) %>% summarise(error_rate = mean(!is_correct), .groups = "drop") %>%
  174. # And now compute the average error rate across subjects, as well as standard error.
  175. summarise(global_error_rate.se = se(error_rate), global_error_rate = mean(error_rate), .groups = "drop") %>%
  176. (function(d){
  177. cat("The average error rate in 6ème is ",
  178. d$global_error_rate[[1]] %>% (scales::percent_format(accuracy = 0.1)),
  179. " (se=",
  180. d$global_error_rate.se[[1]] %>% (scales::percent_format(accuracy = 0.01)), ").", sep = "")
  181. })
  182. ```
  183. ## Error rates per stimulus
  184. Are some stimuli easier than others?
  185. ```{r error rate per stimulus, fig.width = 8.27/2, fig.height = 11.69 / 2}
  186. # Compute error rates per stimulus
  187. error_rates.d = d %>%
  188. # Group by stimulus and category...
  189. group_by(stimulus, category) %>%
  190. # ...and compute error rate and half binomial confidence interval (since we work with binary data)
  191. summarise(error_rate = mean(!is_correct), error_rate.half_ci = half_binomial_ci(error_rate, n()), .groups = "drop") %>%
  192. # Order by error rate to make everything easier to read.
  193. arrange(-error_rate)
  194. ```
  195. Compute the maximum, minimum and 1/2's error rates
  196. ```{r remarkableStimulusErrorRates}
  197. error_rates.d %>%
  198. select(stimulus, error_rate) %>%
  199. filter(error_rate == max(error_rate)) %>%
  200. (function(d){
  201. cat("Maximum error rate:", (scales::percent_format)(accuracy = 2)(d$error_rate[[1]]),
  202. "for fraction", d$stimulus[[1]])
  203. cat(".\n")
  204. })
  205. error_rates.d %>%
  206. select(stimulus, error_rate) %>%
  207. filter(error_rate == min(error_rate)) %>%
  208. (function(d){
  209. cat("Minimum error rate:", (scales::percent_format)(accuracy = 2)(d$error_rate[[1]]),
  210. "for fraction", d$stimulus[[1]])
  211. cat(".\n")
  212. })
  213. error_rates.d %>%
  214. select(stimulus, error_rate) %>%
  215. filter(stimulus == "1 / 2") %>%
  216. (function(d){
  217. cat("Error rate for 1 / 2:", (scales::percent_format)(accuracy = 2)(d$error_rate[[1]]))
  218. cat(".\n")
  219. })
  220. ```
  221. Significance of differences
  222. ```{r error rate per stimulus chi square test and anova}
  223. d %>%
  224. # Select each response as an entry with the stimulus and response correctness
  225. select(stimulus, is_correct) %>%
  226. # Turn this into a table...
  227. table %>%
  228. # On which we perform a chi-square test
  229. chisq.test %>%
  230. # Print it according to APA norms
  231. apa(print = T)
  232. ```
  233. ## Error rates per category
  234. ```{r error rates per category}
  235. # Compute the average error rates for each category.
  236. d %>%
  237. # Compute average error rate per target
  238. # (no need to average by participant since a target was only seen once at most by a given participant)
  239. group_by(stimulus, category) %>% summarise(error_rate = mean(!is_correct), .groups = "drop") %>%
  240. # Now average across stimuli
  241. group_by(category) %>% summarise(error_rate = mean(error_rate), .groups = "drop") %>%
  242. # Order by error rate to make it easier to read.
  243. arrange(-error_rate)
  244. ```
  245. Are differences significant ? We use an anova on error rate by stimulus here, because we would have dependency in the data otherwise.
  246. Note that theoretically we have similar dependency by subject (children saw an unbalanced amount of stimuli per category), but we assume that this will not be a problem because of a large sample size.
  247. ```{r errorRatesPerCategory}
  248. # Not happy with this since participants have not seen the same amount of stimuli in each category
  249. # You can't group by subject either because you then have a dependency on stimulus
  250. d %>%
  251. # For each target, compute error rate
  252. group_by(stimulus, category) %>% summarise(error_rate = mean(!is_correct), .groups = "drop") %>%
  253. # Run an anova by category
  254. rstatix::anova_test(formula = error_rate ~ category) %>%
  255. # Print the results in APA-friendly format
  256. rstatix_anova_to_APA()
  257. ```
  258. # Variations in difficulties - IRT
  259. ## IRT difficulty
  260. ```{r irtDifficulty}
  261. irt_2PL_model.plci = d %>%
  262. # Change correctness to numeric value, which is required for IRT analysis
  263. mutate(is_correct = as.numeric(is_correct)) %>%
  264. # Only keep the relevant stuff for IRT: subject, target, and outcome of the question
  265. select(subj, stimulus, is_correct) %>%
  266. # Format everything with one column per target to fit IRT format requirement.
  267. pivot_wider(
  268. names_from = stimulus,
  269. values_from = is_correct
  270. ) %>%
  271. # Drop the subject model, as IRT does not care about identifiers.
  272. select(-subj) %>%
  273. # Fit IRT model
  274. mirt(itemtype = "2PL", technical = list(NCYCLES = 10000)) %>%
  275. # Get 95% CI - this may be long
  276. PLCI.mirt %>%
  277. # The reported d is easiness, so change it since we want difficulty in order to match error rates
  278. # https://groups.google.com/g/mirt-package/c/RwGmO_fGi2c
  279. mutate(parnam = if_else(parnam == "d", "difficulty", parnam),
  280. value = if_else(parnam == "difficulty", -value, value),
  281. lower_2.5 = if_else(parnam == "difficulty", -lower_2.5, lower_2.5),
  282. upper_97.5 = if_else(parnam == "difficulty", -upper_97.5, upper_97.5))
  283. ```
  284. What is the link between difficulty and error rate
  285. ```{r irtDifficultyAndErrorRateLM}
  286. irt_2PL_model.plci %>%
  287. # Only care about difficulty, not discrimination
  288. filter(parnam == "difficulty") %>%
  289. # Only look at the target and the result
  290. select(Item, value) %>% dplyr::rename(stimulus = Item, difficulty = value) %>%
  291. # Add the average error rate for each target
  292. inner_join(d %>% group_by(stimulus) %>% summarise(error_rate = mean(!is_correct), .groups = "drop"), by = c("stimulus")) %>%
  293. # Compute the linear regression.
  294. lm(formula = error_rate ~ difficulty) %>%
  295. # Print the result according to APA norms
  296. summary %>% regression_summary_to_APA
  297. ```
  298. ### Error rate & IRT figure
  299. ### Figure 2
  300. ```{r errorRateAndDifficultyByStimulus, fig.width = 8.27, fig.height = 11.69/2}
  301. # Make the plot for error rates
  302. error_rates.pl = error_rates.d %>%
  303. # Plot error rate by stimulus (ordered by error rate) and colored by category
  304. ggplot(aes(x = error_rate, y = reorder(stimulus, error_rate), fill = category,
  305. xmin = error_rate, xmax = error_rate + error_rate.half_ci)) +
  306. # Plot the error rate as bars...
  307. geom_bar(stat = "identity") +
  308. # ... with standard error
  309. geom_errorbar(width = 0) +
  310. # Ensure that similar ylims and expansion are used on the two plots!
  311. coord_cartesian(xlim = c(0.6, 1.001), ylim = c(0.5, d$stimulus %>% unique %>% length + 0.5), expand = F) +
  312. # Plot error rate as a percentage
  313. scale_x_continuous(labels = scales::percent_format()) +
  314. # Define axis labels
  315. labs(y = "Target", x = "Error rate", fill = "Category") +
  316. # Make a clean plot
  317. theme(axis.title.y = element_blank(), panel.background = element_blank(), panel.grid = element_blank(),
  318. # Add some lines to delimit everything
  319. axis.line = element_line(colour = "black"), panel.grid.major.x = element_line(colour = "gray", linetype = 1),
  320. # Use the same margin on the two plots!
  321. plot.margin = margin(0, 1,0,0.1, unit = "cm"),
  322. # No use for a legend in this plot.
  323. legend.position = "none")
  324. # Make the difficulty plot
  325. difficulty.pl = irt_2PL_model.plci %>%
  326. # We only care about difficulty
  327. filter(parnam == "difficulty") %>%
  328. # Keep relevant parameters (target, value and 95% CI)
  329. select(Item, value, lower_2.5, upper_97.5) %>%
  330. # Add error rates so that target order matches the error rate plot.
  331. inner_join(d %>% group_by(stimulus, category) %>% summarise(error_rate = mean(!is_correct), .groups = "drop"), by = c("Item" = "stimulus")) %>%
  332. # Plot difficulty by item, with items ordered by error rates just like the others.
  333. ggplot(aes(y = reorder(Item, error_rate), x = value, fill = category,
  334. xmin = lower_2.5, xmax = upper_97.5)) +
  335. # Add lines at hard and very hard boundaries.
  336. geom_vline(xintercept = c(0.5, 2), colour = "red", linetype = 2) +
  337. # Add errorbars
  338. geom_errorbar(width = 0.5) +
  339. # Add points, with a triangle shape like usual IRT plots.
  340. geom_point(size = 5, shape = 22) +
  341. # Make breaks for difficulty every 0.5
  342. scale_x_continuous(breaks = c(0.5, seq(2,8, 2)), labels = function(x){signif(x, 0)}) +
  343. # Ensure that similar ylims and expansion are used on the two plots!
  344. coord_cartesian(xlim = c(0, 8), ylim = c(0.5, d$stimulus %>% unique %>% length + 0.5), expand = F) +
  345. # Add axis labels
  346. labs(x = "Difficulty", y = "Target", fill = "Category") +
  347. # Clean the plot
  348. theme(axis.title.y = element_blank(), panel.background = element_blank(), panel.grid = element_blank(),
  349. # Add some lines to help readability
  350. axis.line = element_line(colour = "black"), panel.grid.major.x = element_line(colour = "gray"),
  351. # Use some margin to make axes legible - use the same margin on the two plots!
  352. plot.margin = margin(t = 0, r = 1, b = 0, l = 0.1, unit = "cm"),
  353. # Place the legend as inset
  354. legend.position = c(0.7, 0.15), legend.background = element_rect(colour = "black"),
  355. # Remove legend grey background
  356. legend.key = element_blank(),
  357. # Just set usual text (this is only meant for tweaking)
  358. legend.text = element_text(hjust = 0),
  359. # Black frame
  360. legend.margin = margin(t = 0.3, r = 1.1, b = 0.3, l = 0.3, "cm"),
  361. # Space between entries
  362. legend.key.height = unit(1, "cm")
  363. ) +
  364. # Specifiy the size of fill indications in the legend.
  365. guides(fill = guide_legend(override.aes = list(size = 7)))
  366. # Create a two-plots figure and save it
  367. ggarrange(error_rates.pl, difficulty.pl, ncol = 2) %>%
  368. save_on_disk("Figure_2", path = "./figures/main/", a4_fraction = c(1, 1/2))
  369. ```
  370. # Non-random errors
  371. ## Precision
  372. ```{r precision}
  373. # Create a function that finds an print the proportion of responses fallling within a given threshold of the correct answer.
  374. (function(threshold){
  375. d %>%
  376. # Drop non-responses and wrong responses????
  377. filter(!actual_response %>% is.na, !is_correct) %>%
  378. # Only keep relevant data
  379. select(stimulus, response, actual_response) %>%
  380. # Compute the distance to the response: if it is below the threshold, call it a close response.
  381. mutate(response_distance = abs(actual_response - response),
  382. is_close_response = response_distance <= threshold) %>%
  383. # For each target, compute the frequency of close response.
  384. group_by(stimulus) %>% summarise(close_response_rate = mean(is_close_response),.groups = "drop") %>%
  385. # Compute the average rate across targets.
  386. summarise(close_response_rate.se = se(close_response_rate), close_response_rate = mean(close_response_rate)) %>%
  387. # Print the result
  388. (function(d){cat("The proportion of responses falling within ", threshold, " of the correct answer is ",
  389. d$close_response_rate[[1]] %>% (scales::percent_format(accuracy=0.1)), "%",
  390. " (se = ", d$close_response_rate.se[[1]] %>% (scales::percent_format(accuracy = 0.01)), "%).",
  391. sep = "")
  392. })
  393. }) (0.25)
  394. ```
  395. ## Imbalanced distributions
  396. Are there stimulus where the response distribution is not uniform?
  397. ```{r distribution of errors per stimulus}
  398. d %>%
  399. # Count possible responses, it happens that each response that was never given is incorrect
  400. count_possible_responses(is_correct) %>% mutate(is_correct = is_correct %>% replace_na(F)) %>%
  401. # Only consider erroneous responses: drop non-responses and correct responses
  402. filter(!actual_response %>% is.na, !is_correct) %>%
  403. # Only care about the number of responses for each position and target -- the target itself is not relevant
  404. select(stimulus, n) %>%
  405. # Perform a chi-square test on each target and put the results in adequate columns.
  406. group_by(stimulus) %>% summarise(test = chisq.test(n) %>% (broom::tidy), .groups = "drop") %>% unnest %>%
  407. # Rename columns (and drop useless method report)
  408. dplyr::rename(X2 = statistic, df = parameter, p = p.value) %>% select(-method) %>%
  409. # FDR-correction for p-values, and put them in nice APA format
  410. mutate(p = p %>% p.adjust("fdr") %>% p_value_to_APA()) %>%
  411. # Order target from most significative test to the least significative one.
  412. arrange(X2) %>%
  413. # Round as per APA format
  414. mutate(X2 = X2 %>% flexible_round(2)) %>%
  415. # Report results in a human-friendly format.
  416. mutate(stats = str_c("\\Chi2(", df, ") = ", X2, ", ", p), .keep = "unused")
  417. ```
  418. Can we explain the imbalance in responses based on integers?
  419. ```{r error distribution without integer}
  420. d %>%
  421. # Count possible responses, it happens that each response that was never given is incorrect
  422. count_possible_responses(is_correct) %>% mutate(is_correct = is_correct %>% replace_na(F)) %>%
  423. # Identify integer responses
  424. mutate(is_integer = actual_response %>% round == actual_response) %>%
  425. # Only consider erroneous responses: drop non-responses, correct responses, and integer responses
  426. filter(!actual_response %>% is.na, !is_correct, !is_integer) %>%
  427. # For each target, perform a chi-square test and put the results in adequate columns.
  428. group_by(stimulus) %>% summarise(test = chisq.test(n) %>% (broom::tidy), .groups = "drop") %>% unnest %>%
  429. # Rename columns (and drop useless method report)
  430. dplyr::rename(X2 = statistic, df = parameter, p = p.value) %>% select(-method) %>%
  431. # FDR-correction for p-values, and put them in nice APA format
  432. mutate(p = p %>% p.adjust("fdr") %>% p_value_to_APA()) %>%
  433. # Order target from most significative test to the least significative one.
  434. arrange(X2) %>%
  435. # Round as per APA format
  436. mutate(X2 = X2 %>% flexible_round(2)) %>%
  437. # Report results in a human-friendly format.
  438. mutate(stats = str_c("\\Chi2(", df, ") = ", X2, ", ", p), .keep = "unused")
  439. ```
  440. All the targets display a significant imbalance of error positions. The df varies between 43 and 44 across tests because sometimes the correct response is an integer (so we remove only 6 response positions for the test), sometimes it isn't (so we remove 7 response positions).
  441. To what extent are error positions falling on a restricted set of responses?
  442. ```{r error position stacking}
  443. d %>%
  444. # Drop non-responses and correct responses
  445. filter(!actual_response %>% is.na, !is_correct) %>%
  446. # Count how many times each response was given for each target.
  447. count(stimulus, actual_response) %>%
  448. # Perform this analysis per target
  449. group_by(stimulus) %>%
  450. # Turn the count into a proportion per target.
  451. mutate(proportion = n / sum(n)) %>%
  452. # Arrange responses by decreasing proportion: most given responses are on top...
  453. arrange(stimulus, -proportion) %>%
  454. # And now make a cumulated sum of the proportions: each sum covers the most given responses since we ordered them above!
  455. mutate(cumsummed_proportion = cumsum(proportion),
  456. # Also count the number of positions included in the cumsum.
  457. actual_response_index = 1:n()) %>%
  458. # Only keep the cumsums after which the 50% of responses threshold was hit.
  459. filter(cumsummed_proportion > .50) %>%
  460. # And keep the one with the least responses included: this is how many positions are required to reach the threshold (again because we ordered them by decreasing order of proportion of responses before)
  461. filter(actual_response_index == min(actual_response_index)) %>%
  462. # Ungroup, we now consider results across targets again.
  463. ungroup %>%
  464. # Only keep relevant info: targets, and the number of positions. required to reach 50% of responses.
  465. select(stimulus, actual_response_index) %>%
  466. # Compute the average number of responses required, and print the result in a nice
  467. summarise(mean_index = mean(actual_response_index)) %>%
  468. (function(d){cat("On average, half the responses fall on", flexible_round(d$mean_index, 2), "positions.")})
  469. ```
  470. # Inversion
  471. ## Four targets
  472. First, let's consider four remarkable targets: 1/2 (a basic fraction, if not the most basic one); 3/6 (an equivalent to 1/2); 2/1 (the inverse of 1/2); and 6/3 (the inverse of 3/6)
  473. ```{r four targets example, fig.width = 8.27, fig.height = 11.69/2}
  474. (d %>%
  475. # Only keep our targets of interest
  476. filter(stimulus %in% c("1 / 2", "3 / 6", "2 / 1", "6 / 3"),
  477. # Drop non-responses.
  478. !actual_response %>% is.na) %>%
  479. # Count the number of responses per target (also keep some info about the target like components and whether the answer is correct or not)
  480. group_by(stimulus, numerator, denominator, response) %>% count(actual_response, is_correct) %>%
  481. # Turn the number of responses into a proportion per target, and count the total number of responses per target.
  482. group_by(stimulus) %>% mutate(proportion = n / sum(n), sample_size_label = str_c("n = ", sum(n))) %>%
  483. # Add a some infos about the target as label for later plotting.
  484. # We only add a single label per target to avoid cluttering the plot, on the correct response for convenience
  485. # (Note that here we have at least one correct responses per target, but this line may fail if there is none.)
  486. mutate(sample_size_label = if_else(is_correct, sample_size_label, NA),
  487. numerator_label = if_else(is_correct, numerator, NA),
  488. denominator_label = if_else(is_correct, denominator, NA),
  489. response_label_position = if_else(is_correct, response, NA)) %>%
  490. ungroup %>%
  491. # Match responses with predictions from our patterns to mark the responses on the plot
  492. left_join(
  493. # If several patterns yield the same prediction, only keep the first one as it is just illustrative
  494. fractions_predictions.d %>% group_by(stimulus, prediction) %>% filter(1:n() == 1) %>%
  495. # Separate the 'normative' pattern into correct (without inversion3) and inverted
  496. mutate(pattern = if_else(pattern == "Normative", if_else(inverted, "Inverted", "Correct"), pattern)),
  497. # Join the predictions with actual responses
  498. by = c("stimulus", "actual_response" = "prediction")) %>%
  499. # Order the stimuli neatly for the facetted display
  500. mutate(stimulus = stimulus %>% factor(c("1 / 2", "3 / 6", "2 / 1", "6 / 3"))) %>%
  501. # Order the patterns neatly for the legend
  502. mutate(pattern = pattern %>% factor(levels =
  503. patterns.colours[patterns.colours %>% names != "Normative"] %>% names %>%
  504. # Add blanks to correctly shape the columns - go in decreasing order to avoid shifting indices
  505. append(c("BLANK3", "BLANK2"), after = 5) %>% append("BLANK1", after = 3)
  506. )) %>%
  507. # Plot the proportion of responses per position, filling the bars matching specific patterns
  508. ggplot(aes(x = actual_response, y = proportion, fill = pattern)) +
  509. # Add bars to indicate the proportion of resopnses.
  510. geom_bar(stat = "identity") +
  511. # Divide by size in mm by .pt to turn it into point, and then multiply by PT_TO_PIXEL to correct SVG saving sizes in pixels.
  512. # Write the sample size in the top right corner
  513. geom_text(aes(label = sample_size_label), x = 4.5, y = .36, size = 15 * PT_TO_PIXEL / .pt, family = FAMILY) +
  514. # Write the target fraction above the response (the correct one, since all others have NAs)
  515. geom_text(aes(label = numerator_label, x = response), y = .38, size = 18 * PT_TO_PIXEL / .pt, family = FAMILY) +
  516. geom_text(aes(label = denominator_label, x = response), y = .34, size = 18 * PT_TO_PIXEL / .pt, family = FAMILY) +
  517. # Write the vinculum of the fraction
  518. geom_segment(aes(x = response_label_position - 0.1, xend = response_label_position + 0.1),
  519. y = .36, yend = .36, linewidth = 0.5) +
  520. # Indicate the correct response with an arrow poiting to it.
  521. ## Body
  522. geom_segment(aes(x = response_label_position, xend = response_label_position,
  523. y = proportion + 0.02),
  524. yend = .31) +
  525. ## Head
  526. geom_segment(aes(x = response_label_position, xend = response_label_position - 0.1,
  527. y = proportion + 0.02, yend = proportion + 0.04)) +
  528. geom_segment(aes(x = response_label_position, xend = response_label_position + 0.1,
  529. y = proportion + 0.02, yend = proportion + 0.04)) +
  530. # Create one subplot for each of the four targets, in a 2x2 display
  531. facet_wrap(~stimulus, nrow = 2, scales = "free_x") +
  532. # Indicate axes labels
  533. labs(x = "Responded position", y = "% of responses", fill = "Response type") +
  534. # Set the limits of the y axis (determined by hand) and display % of responses as actual %s.
  535. scale_y_continuous(limits = c(0, 0.4), labels = scales::percent_format()) +
  536. # Clean the panel to make it easier to read.
  537. theme(panel.background = element_blank(), panel.grid = element_blank(), panel.spacing = unit(0.5, "cm"),
  538. # Add some margins to space everything.
  539. plot.margin = margin(t = 0.6, r = 0.5, b = 0.5, l = 0.5, "cm"),
  540. # Remove the strip from facet plot
  541. strip.background = element_blank(), strip.text = element_blank(),
  542. # Make the titles
  543. #axis.title = element_text(),
  544. axis.title.x = element_text(hjust = 0.14, vjust = 0),
  545. axis.line = element_line(colour = "black"), panel.grid.major.y = element_line(colour = "gray", linetype = 1),
  546. legend.position = "bottom",
  547. # Remove grey background from keys
  548. legend.key = element_blank(), legend.title = element_text(vjust = 0.75)) +
  549. # legend.position = "none") +
  550. # Add a horizontal y axis legend - small hacky stuff with subtitle
  551. theme(axis.title.y = element_blank(),
  552. plot.title = element_blank(), plot.subtitle = element_text(hjust = -0.08, vjust = 4)) +
  553. ggtitle("", subtitle = "% of responses") +
  554. scale_x_decimal_number_line(expand = F) +
  555. guides(fill = guide_legend(ncol = 3), override.aes = aes(width = 1)) +
  556. # This is a very hacky way to get the shape of the legend right
  557. scale_fill_manual(values = c(patterns.colours, "BLANK1" = "white", "BLANK2" = "white", "BLANK3" = "white"),
  558. labels = Vectorize(function(p){
  559. if(p == "BLANK1") return("to the line")
  560. else if(!p %in% names(pretty_pattern_names)) return("")
  561. else pretty_pattern_names[[p]] %>% str_remove(" to the line")}),
  562. drop = FALSE
  563. )
  564. ) %>%
  565. save_on_disk("Figure_3", path = "./figures/main/SVG/", device = "svg", a4_fraction = c(1, .60), indicate_format = F)
  566. ```
  567. ## Pairwise confusion
  568. Compute the similarity between distributions for pairs of inverted fractions.
  569. ```{r fig.width= 8.27/2, fig.height = 11.69/2}
  570. # Compute the list of pairs of inverse fractions, ordered to make plots using it easier to read.
  571. ordered_inverse_targets_pairs = d %>%
  572. # Only consider the targets and their components
  573. select(stimulus, numerator, denominator) %>% unique %>%
  574. # Sort the components so that pairs of inverse fractions have the same components in the same order
  575. mutate(smaller_component = pmin(numerator, denominator),
  576. greater_component = pmax(numerator, denominator)) %>%
  577. # Only keep fractions where you can build a pair with same components (i.e., inverted fractions)
  578. group_by(smaller_component, greater_component) %>% filter(n() == 2) %>%
  579. # Reorder everything pairwise to have an order for future plots
  580. arrange(greater_component, smaller_component, numerator) %$%
  581. # Keep the list of targets
  582. stimulus
  583. # Compute the correlation between distributions -- to show that it is greater for inverted fractions
  584. inverted_pairs_distributions_correlations = d %>%
  585. # Count the number of responses per position for each target, including possible positions without any response, since we care about distributions.
  586. count_possible_responses(numerator, denominator) %>%
  587. # Only keep stimuli for which an inverse fraction was presented
  588. filter(stimulus %in% ordered_inverse_targets_pairs) %>%
  589. # Keep relevant infos: target, response position, number of responses.
  590. select(stimulus, actual_response, n) %>%
  591. # For each position, count the number of responses that fell on it per target
  592. group_by(stimulus) %>% pivot_wider(names_from = stimulus, values_from = n) %>%
  593. # Now drop the exact positions, since only care about the distributions
  594. select(-actual_response) %>%
  595. # Compute the correlation between each line of the matrix...
  596. cor %>%
  597. # ...and report it with one line per pair.
  598. melt %>%
  599. # Rename the columns and make the targets into factor for a consistent order.
  600. mutate(target1 = X1 %>% factor(levels = ordered_inverse_targets_pairs),
  601. target2 = X2 %>% factor(levels = ordered_inverse_targets_pairs),
  602. pearson_r = value,
  603. .keep = "unused")
  604. # Make a plot
  605. (inverted_pairs_distributions_correlations %>%
  606. # Identify target 1 as x_label
  607. dplyr::rename(x_label = target1) %>%
  608. # Reinject numerators and denominators to make computations with them on x label.
  609. inner_join(d %>% select(stimulus, numerator, denominator) %>% unique,
  610. by = c("x_label" = "stimulus")) %>%
  611. # Place fractions in a pair on alternating lines, by adding a line return for those > 1
  612. mutate(x_label = str_c(if_else(numerator > denominator, "\n", ""), x_label %>% str_remove_all(" "))) %>%
  613. # Plot the correlation between the first target (x-axis) and second target (y-axis)
  614. ggplot(aes(x = reorder(x_label, target1 %>% as.numeric), y = target2, fill = pearson_r)) +
  615. # Tile with pearson correlation between distributions
  616. geom_tile(color = NA) +
  617. # Ensure that the matrix is a square
  618. coord_fixed() +
  619. # Set a nicer scale for filling the tiles
  620. scale_fill_viridis_c(option = "magma") +
  621. # Set labels
  622. labs(x = "Target 1", y = "Target 2", fill = "Pearson's r") +
  623. # Clean the plot
  624. theme(panel.background = element_blank(), panel.grid = element_blank(),
  625. # Drop the axes ticks and title
  626. axis.ticks = element_blank(), axis.title = element_blank(),
  627. # Set the legend
  628. legend.position = "top", legend.key.width = unit(1.5, "cm"), legend.title = element_text(vjust = 1))
  629. ) %>%
  630. # Save
  631. save_on_disk("Figure_4", path = "./figures/main/SVG/", a4_fraction = c(1/2, 1/2), indicate_format = F)
  632. ```
  633. Is the correlation stronger within pairs (big squares on the diagonal) than across?
  634. ```{r correlationForInvertedPairsSummary}
  635. # Compute the correlation for each non-ordered pair
  636. non_ordered_pairs_distributions_correlations = inverted_pairs_distributions_correlations %>%
  637. # Add back the numerator and denominator of target 1
  638. inner_join(d %>% select(stimulus, numerator, denominator) %>% unique %>% dplyr::rename(target1 = stimulus, numerator1 = numerator, denominator1 = denominator),
  639. by = c("target1")) %>%
  640. # Add back the numerator and denominator of target 1
  641. inner_join(d %>% select(stimulus, numerator, denominator) %>% unique %>% dplyr::rename(target2 = stimulus, numerator2 = numerator, denominator2 = denominator),
  642. by = c("target2")) %>%
  643. # We only want to consider non-ordered pairs, so only keep those following the lexicographic order
  644. filter(numerator1 < numerator2
  645. | numerator1 == numerator2 & denominator1 < denominator2) %>%
  646. # Flag inverted pairs
  647. mutate(is_inverted_pair = numerator1 == denominator2 & denominator1 == numerator2, .keep = "unused")
  648. non_ordered_pairs_distributions_correlations %>%
  649. # Compute the average correlation within and across inverted pairs
  650. group_by(is_inverted_pair) %>% summarise(
  651. pearson_r.se = se(pearson_r), pearson_r = mean(pearson_r), .groups = "drop") %>%
  652. # Report the results
  653. select(is_inverted_pair, pearson_r, pearson_r.se)
  654. ```
  655. ```{r correlationForInvertedPairsStats}
  656. # Look how the inverted pairs rank in terms of correlations compared to others
  657. non_ordered_pairs_distributions_correlations %>% arrange(-pearson_r)
  658. ```
  659. There is only one non-inverted pair for which the correlation is better than a single inverted pair
  660. Since we have 6 relevant pairs (those of inverted pairs) and 66 pairs in total, there are 6C66 possible orderings (~90,858,768)
  661. The only best ordering would have been one where the 6 inverted pairs had a better correlation than all others, so we have the 2nd to best ordering(with 6 targets, that is inverse pairs): this means that in exact permutation test, p = 2/(6C66) = 2/~90,858,768 < .001
  662. # Global error patterns
  663. Now evaluate the patterns across all targets
  664. ```{r total prediction}
  665. d %>%
  666. # Remove non-responses
  667. filter(!actual_response %>% is.na, !is_correct) %>%
  668. # Count reponses per stimulus and position
  669. count(stimulus, actual_response) %>%
  670. # Convert the count into a proportion to allow for comparison across stimulus
  671. group_by(stimulus) %>% mutate(proportion = n/sum(n)) %>% ungroup %>%
  672. # Only keep stimulus-response pairs that are also in the predictions df.
  673. # Drop the patterns themselves as we don't care about them
  674. inner_join(fractions_predictions.d %>% select(stimulus, prediction) %>% unique,
  675. by = c("stimulus", "actual_response" = "prediction")) %>%
  676. # Since we have disjoint entries in the set, we can compute the proportion explained per stimulus as the sum.
  677. group_by(stimulus) %>% summarise(proportion_explained = sum(proportion), .groups = "drop") %>%
  678. # Now compute the average proportion across all stimuli
  679. mutate(mean_proportion_explained = mean(proportion_explained)) %>%
  680. # And order them from the least explained.
  681. arrange(proportion_explained)
  682. ```
  683. Compute the proportion of non-ambiguously explained responses
  684. ```{r unambiguously predicted}
  685. d %>%
  686. # Ignore non-responses
  687. filter(!actual_response %>% is.na) %>%
  688. # Count each response on each stimulus
  689. count_possible_responses() %>%
  690. # Turn count into proportion to allow for comparison across stimuli
  691. group_by(stimulus) %>% mutate(proportion = n/sum(n)) %>% ungroup %>%
  692. # Match responses to pattern prediction, but only when not ambiguous!
  693. inner_join(fractions_predictions.d %>% filter(!ambiguous),
  694. by = c("stimulus", "actual_response" = "prediction")) %>%
  695. # Compute the proportion of responses explained by a pattern across stimuli
  696. group_by(pattern, inverted) %>% summarise(proportion.se = se(proportion), proportion = mean(proportion), .groups = "drop") %>%
  697. # Reorder the columns...
  698. select(pattern, inverted, proportion, proportion.se) %>%
  699. # ...and round to ease readability
  700. mutate(proportion = scales::label_percent(accuracy = 0.1)(proportion),
  701. proportion.se = scales::label_percent(accuracy = 0.1)(proportion.se))
  702. ```
  703. ## Overrepresentation analyses across all predictions
  704. Use all targets
  705. ```{r global overrepresentation with all targets}
  706. d %>%
  707. # Remove non-responses
  708. filter(!actual_response %>% is.na) %>%
  709. # Count the number of responses on all positions
  710. count_possible_responses(stimulus, actual_response) %>%
  711. # Just order by stim and response for easier readability
  712. arrange(stimulus, actual_response) %>%
  713. # Turn count into proportion
  714. group_by(stimulus) %>% mutate(proportion = n / sum(n)) %>%
  715. # Compute the median proportion of responses on each position
  716. group_by(actual_response) %>% mutate(median_proportion_on_position = median(proportion)) %>%
  717. # Match responses to predictions -- only keep non ambiguous cases to avoid inflating our results from other patterns
  718. inner_join(fractions_predictions.d %>% filter(!ambiguous),
  719. by = c("stimulus", "actual_response" = "prediction")) %>%
  720. # For each pattern (with/without inversion) do a paired wilcoxon test on whether the proportion is consistently greater than the median. Compute also the mean proportion and number of targets as supplementary info
  721. group_by(pattern, inverted) %>%
  722. summarise(number_of_unambiguous_targets = n(),
  723. test = wilcox.test(proportion, median_proportion_on_position, paired = T, alternative = "greater") %>% (broom::tidy),
  724. mean_proportion = mean(proportion),
  725. .groups = "drop") %>%
  726. # Unpack test data
  727. unnest(cols = test) %>%
  728. # Correct for multiple testing
  729. mutate(p.value = p.value %>% p.adjust("fdr")) %>%
  730. # Order by pattern to ease readability2
  731. mutate(pattern = factor(pattern, levels = patterns.colours %>% names)) %>% arrange(pattern) %>%
  732. # Clean the report -- merge pattern name and inversion
  733. mutate(pattern = str_c(pattern, if_else(inverted, " (inverted)", "")), .keep = "unused") %>%
  734. # Turn statistics into APA format
  735. mutate(p.value = p.value %>% p_value_to_APA, mean_proportion = scales::percent_format(accuracy = 0.1)(mean_proportion)) %>%
  736. # Reorder and only keep relevant columns
  737. select(pattern, p.value, mean_proportion, number_of_unambiguous_targets, statistic)
  738. ```
  739. Exclude the four examples
  740. ```{r global overrepresentation with naive targets}
  741. d %>%
  742. # Remove non-responses
  743. filter(!actual_response %>% is.na) %>%
  744. # Ignore the worked out examples
  745. filter(!stimulus %in% c("1 / 2", "2 / 1", "3 / 6", "6 / 3")) %>%
  746. # Count the number of responses on all positions
  747. count_possible_responses(stimulus, actual_response) %>%
  748. # Just order by stim and response for easier readability
  749. arrange(stimulus, actual_response) %>%
  750. # Turn count into proportion
  751. group_by(stimulus) %>% mutate(proportion = n / sum(n)) %>%
  752. # Compute the median proportion of responses on each position
  753. group_by(actual_response) %>% mutate(median_proportion_on_position = median(proportion)) %>%
  754. # Match responses to predictions -- only keep non ambiguous cases to avoid inflating our results from other patterns
  755. inner_join(fractions_predictions.d %>% filter(!ambiguous),
  756. by = c("stimulus", "actual_response" = "prediction")) %>%
  757. # For each pattern (with/without inversion) do a paired wilcoxon test on whether the proportion is consistently greater than the median. Compute also the mean proportion and number of targets as supplementary info
  758. group_by(pattern, inverted) %>%
  759. summarise(number_of_unambiguous_targets = n(),
  760. test = wilcox.test(proportion, median_proportion_on_position, paired = T, alternative = "greater") %>% (broom::tidy),
  761. mean_proportion = mean(proportion),
  762. .groups = "drop") %>%
  763. # Unpack test data
  764. unnest(cols = test) %>%
  765. # Correct for multiple testing
  766. mutate(p.value = p.value %>% p.adjust("fdr")) %>%
  767. # Order by pattern to ease readability2
  768. mutate(pattern = factor(pattern, levels = patterns.colours %>% names)) %>% arrange(pattern) %>%
  769. # Clean the report -- merge pattern name and inversion
  770. mutate(pattern = str_c(pattern, if_else(inverted, " (inverted)", "")), .keep = "unused") %>%
  771. # Turn statistics into APA format
  772. mutate(p.value = p.value %>% p_value_to_APA, mean_proportion = scales::percent_format(accuracy = 0.1)(mean_proportion)) %>%
  773. # Reorder and only keep relevant columns
  774. select(pattern, p.value, mean_proportion, number_of_unambiguous_targets, statistic)
  775. ```
  776. Restrict some patterns
  777. ```{r global overrepresentation with restricted targets}
  778. d %>%
  779. # Remove non-responses
  780. filter(!actual_response %>% is.na) %>%
  781. # Count the number of responses on all positions
  782. count_possible_responses(stimulus, actual_response) %>%
  783. # Just order by stim and response for easier readability
  784. arrange(stimulus, actual_response) %>%
  785. # Turn count into proportion
  786. group_by(stimulus) %>% mutate(proportion = n / sum(n)) %>%
  787. # Compute the median proportion of responses on each position
  788. group_by(actual_response) %>% mutate(median_proportion_on_position = median(proportion)) %>%
  789. # Match responses to predictions -- only keep non ambiguous cases to avoid inflating our results from other patterns
  790. inner_join(fractions_predictions.d %>%
  791. filter(!ambiguous,
  792. !pattern %in% c("Relative", "Mixed")
  793. | pattern == "Relative" & prediction == 2.5
  794. | pattern == "Mixed" & stimulus %>% str_count(" / 2") == 1
  795. ),
  796. by = c("stimulus", "actual_response" = "prediction")) %>%
  797. # For each pattern (with/without inversion) do a paired wilcoxon test on whether the proportion is consistently greater than the median. Compute also the mean proportion and number of targets as supplementary info
  798. group_by(pattern, inverted) %>%
  799. summarise(number_of_unambiguous_targets = n(),
  800. test = wilcox.test(proportion, median_proportion_on_position, paired = T, alternative = "greater") %>% (broom::tidy),
  801. mean_proportion = mean(proportion),
  802. .groups = "drop") %>%
  803. # Unpack test data
  804. unnest(cols = test) %>%
  805. # Correct for multiple testing
  806. mutate(p.value = p.value %>% p.adjust("fdr")) %>%
  807. # Order by pattern to ease readability2
  808. mutate(pattern = factor(pattern, levels = patterns.colours %>% names)) %>% arrange(pattern) %>%
  809. # Clean the report -- merge pattern name and inversion
  810. mutate(pattern = str_c(pattern, if_else(inverted, " (inverted)", "")), .keep = "unused") %>%
  811. # Turn statistics into APA format
  812. mutate(p.value = p.value %>% p_value_to_APA, mean_proportion = scales::percent_format(accuracy = 0.1)(mean_proportion)) %>%
  813. # Reorder and only keep relevant columns
  814. select(pattern, p.value, mean_proportion, number_of_unambiguous_targets, statistic)
  815. ```
  816. ## Overrepresentation of individual predictions
  817. ```{r overrepresentation of individual predictions pvalues}
  818. overrepresentation.d = d %>%
  819. # Drop non-responses
  820. filter(!actual_response %>% is.na) %>%
  821. # Count answers
  822. count_possible_responses(stimulus, actual_response) %>%
  823. # Plug back some information
  824. # - Category, numerator, denominator for ordering
  825. inner_join(d %>% select(stimulus, category, numerator, denominator, response) %>% unique,
  826. by = c("stimulus")) %>%
  827. # Turn count into proportion
  828. group_by(stimulus) %>% mutate(total_n = sum(n), proportion = n / sum(n), ) %>%
  829. # Compute the median proportion of responses on each position
  830. group_by(actual_response) %>% mutate(median_proportion_on_position = median(proportion)) %>% ungroup %>%
  831. # Match the responses with patterns
  832. # Do not care about ambiguity since we are concerned about individual response positions
  833. inner_join(fractions_predictions.d %>% select(stimulus, prediction, pattern, inverted),
  834. by = c("stimulus", "actual_response" = "prediction")) %>%
  835. # Order by pattern for readability
  836. arrange(pattern, inverted) %>%
  837. # Do a one-proportion t-test, for each entry, against the median proportion
  838. mutate(test = Vectorize(function(x, n, p){prop.test(x, n, p = p, alternative = "greater") %>% (broom::tidy) %>% list})(n, total_n, median_proportion_on_position)) %>% unnest(cols = c(test)) %>%
  839. # Only keep some relevant values and reorder them
  840. select(pattern, inverted, stimulus, actual_response, proportion, statistic, parameter, p.value, median_proportion_on_position, n, total_n) %>%
  841. # Correct p-values
  842. mutate(p.value = p.value %>% p.adjust("fdr"))
  843. overrepresentation.d %>%
  844. # Format statistics
  845. mutate(p_value = p.value %>% p_value_to_APA,
  846. proportion = proportion %>% (scales::percent_format(accuracy = 1)),
  847. z = statistic %>% flexible_round(digits = 2),
  848. .keep = "unused") %>%
  849. # Report statistical test as per APA standards
  850. mutate(statistical_test = str_c("z(", parameter, ", ", total_n, ") = ", z), .keep = "unused") %>%
  851. # Merge pattern name and inversion
  852. mutate(pattern = str_c(pattern, if_else(inverted, "_inverted", "")), .keep = "unused") %>%
  853. # Reorder relevant columns
  854. select(pattern, stimulus, proportion, statistical_test, p_value)
  855. ```
  856. #### Figure 5
  857. Plot the distribution of responses as well as some focus on specific patterns
  858. ```{r global response distribution, fig.width = 8.27}
  859. full_response_patterns.pl = d %>%
  860. # Drop non-responses
  861. filter(!actual_response %>% is.na) %>%
  862. # Count the number of responses per target and position, also keep some useful data
  863. count(stimulus, category, numerator, denominator, is_correct, actual_response) %>%
  864. # Turn count into proportion per target
  865. group_by(stimulus) %>% mutate(proportion = n / sum(n)) %>% ungroup() %>%
  866. # Order the categories
  867. mutate(category = category %>% factor(c("n / 1", "Tenths", "Integer result", "Equivalent to 1/2"))) %>%
  868. # Plot the distribution of response, with targets ordered
  869. # - by category
  870. # - then by denominator,
  871. # - then by numerator
  872. ggplot(aes(x = actual_response,
  873. y = reorder(stimulus, 10000 * as.numeric(category) + 100 * denominator + numerator))) +
  874. # Fill each tile with the proportion of correct responses
  875. geom_tile(aes(fill = proportion)) +
  876. # Add an outline to correct responses
  877. geom_tile(aes(colour = is_correct, linewidth = is_correct), alpha = 0) +
  878. # Add a number line for the x-axis
  879. scale_x_decimal_number_line(expand = F, major.tick.size = 7) +
  880. # Make the y-axis neater
  881. scale_y_discrete(guide = "prism_offset") +
  882. # Color the correct responses with the color of the 'Correct' pattern for consistency
  883. scale_colour_manual(values = c("TRUE" = patterns.colours[["Normative"]]), breaks = c("TRUE"), labels = "Correct response") +
  884. # Set the size of the outline of the correct responses
  885. scale_linewidth_manual(values = c("TRUE" = 1.25), breaks = c()) +
  886. # Set the fill scale for the proportion of responses as a grey-scale gradient
  887. scale_fill_gradient(low = "white", high = "black", labels = scales::percent_format(), limits = c(0, .4), breaks = seq(0, .3, .1)) +
  888. # Set the linewith for the 'correct' response legend.
  889. guides(colour = guide_legend(override.aes = list(linewidth = 1.25), title = NULL)) +
  890. # Set nicer labels for axes
  891. labs(x = "Response position", y = "Target fraction", fill = "% of responses", colour = "Correct responses") +
  892. # Set y label in a horizontal position
  893. horizontal_ylab("Target\nfraction", vjust = 1.15) +
  894. # Set the title to be a tiny bit greater.
  895. theme_text(size = 14, title.size = 16, scaling_factor = PT_TO_PIXEL) +
  896. # Clean the plot
  897. theme(panel.background = element_blank(), panel.grid = element_blank(),
  898. panel.grid.major.y = element_blank(),
  899. # Set a line on the y-axis
  900. axis.line = element_line(colour = "black"),
  901. # Make the legend prettier
  902. legend.key = element_blank(), legend.position = "bottom",
  903. legend.text = element_text(vjust = 0, hjust = 0.5),
  904. legend.box.just = "right",
  905. legend.title = element_text(vjust = 0.9), legend.key.width = unit(1.6, "cm"),
  906. legend.spacing.y = unit(0, "mm"),
  907. # Adapt the title of the plot
  908. title = element_text(margin = margin(b = 0.5, unit = "cm"))) +
  909. # Set the title
  910. ggtitle("Full distribution of responses")
  911. # Load overrepresentation helper
  912. source("./src/overrepresentation_plot.R")
  913. # Make a 4-plot solution with the distribution in the top left corner and some overrepresentation lots in the others
  914. # GGarrange from egg package handles legends magnificiently
  915. egg::ggarrange(
  916. # Full distributions
  917. full_response_patterns.pl + theme(legend.box = "vertical"),
  918. # Overrepresentation plot for 'Relative' error pattern
  919. overrepresentation.d %>% overrepresentation_plot("Relative", plot_title = "Proportion of errors of type\nCorrect relative to the line"),
  920. # Overrepresentation plot for 'Count of units' error pattern
  921. overrepresentation.d %>% overrepresentation_plot("Counting_units", plot_title = "Proportion of errors of type\nCounting the numerator"),
  922. # Overrepresentation plot for 'Decimal' error pattern
  923. overrepresentation.d %>% overrepresentation_plot("Decimal", plot_title = "Proportion of errors of type\nDecimal reading"),
  924. # Make it 2x2
  925. ncol = 2, nrow = 2
  926. ) %>%
  927. # Save
  928. save_on_disk("Figure_5", path = "./figures/main/SVG/", device = "svg", a4_fraction = c(1, 3/4), indicate_format = F)
  929. ```
  930. ### Inversion
  931. Plot the frequency of answers applying the pattern on the fraction read in the conventional order or in the opposite one.
  932. ```{r inversion, fig.width = 8.27, fig.height = 11.69 / 4}
  933. # Save the data
  934. inversion.d = d %>%
  935. # Count responses -- we want all responses because we want to include non-realised predictions of the patterns
  936. count_possible_responses() %>%
  937. # Match responses with predictions from patterns...
  938. inner_join(
  939. # ...but only keep predictions that are unambiguous and have an unambiguous inverted counterpart
  940. fractions_predictions.d %>% filter(!ambiguous) %>% group_by(stimulus, pattern) %>% filter(n() == 2),
  941. by = c("stimulus", "actual_response" = "prediction")
  942. ) %>%
  943. # We no longer care about the actual value of the positions
  944. select(-actual_response) %>%
  945. # Compute proportions of responses on target for each position
  946. group_by(stimulus, pattern) %>% mutate(proportion = n / sum(n), proportion_half_ci = half_binomial_ci(proportion, sum(n))) %>% ungroup() %>%
  947. # Turn inverted into a label to make a cleaner pivot_wider
  948. mutate(label = if_else(inverted, "_inverted", "_direct"), .keep = "unused") %>%
  949. # Pivot so that every pattern-stimulus pair is matched with its _direct and _inverted proportions
  950. pivot_wider(names_from = label, values_from = c(proportion, n, proportion_half_ci), names_sep = "") %>%
  951. # The confidence interval is the same for direct and inversion matches, so remove the duplicate
  952. select(-proportion_half_ci_inverted) %>% dplyr::rename(proportion_half_ci = proportion_half_ci_direct) %>%
  953. # Retrieve useful data about the stimuli
  954. inner_join(d %>% select(stimulus, response, numerator, denominator) %>% unique, by = c("stimulus")) %>%
  955. # Reorder stimuli for comparison on graphs...
  956. arrange(pattern, !response %in% c(0.5, 2),
  957. pmax(denominator, numerator), pmin(denominator, numerator), numerator, denominator) %>%
  958. # And save this order into a dedicated column
  959. group_by(pattern) %>% mutate(order = 1:n()) %>% ungroup
  960. ```
  961. Are there differences in inversion across stimuli for some patterns?
  962. ```{r inversion difference between stimuli}
  963. inversion.d %>%
  964. # Chi-square proportion test over responses matching the direct pattern vs. those matching any of the two
  965. group_by(pattern) %>% summarise(distribution_test = prop.test(n_direct, n_direct + n_inverted) %>% (broom::tidy), .groups = "drop") %>% unnest(cols = c(distribution_test)) %>%
  966. # Correct and format statistics nicely
  967. mutate(z_test = str_c("X2(", parameter, ") = ", statistic %>% flexible_round(2)),
  968. # Fdr-correction for p-values
  969. p_value = p.value %>% p.adjust("fdr") %>% p_value_to_APA,
  970. alternative = alternative,
  971. pattern = pattern, .keep = "none")
  972. ```
  973. ```{r inversions plot, fig.width = 8.27, fig.height = 11.69 * 2/3}
  974. # Create a helper function for inversion plot, displaying the bias towards applying the pattern on the target fraction or its inverse.
  975. inversion_plot = function(data, pattern_name, never_inverted_name = "Never\ninverted", always_inverted_name = "Always\ninverted", use_quincunx_for_x_axis = F){
  976. data %>%
  977. # Place stimuli on a W shape -- only if asked for
  978. mutate(stimulus = if_else(order %% 2 == 0 & use_quincunx_for_x_axis, "\n", "") %>% str_c(stimulus)) %>%
  979. # Plot proportion of inversion per stimulus
  980. ggplot(aes(x = reorder(stimulus, order), y = proportion_direct - 0.5, fill = pattern,
  981. ymin = proportion_direct - 0.5 - proportion_half_ci, ymax = proportion_direct - 0.5 + proportion_half_ci)) +
  982. # Proportions are bars
  983. geom_bar(stat = "identity") +
  984. # Add half binominal confidence interval as error bars
  985. geom_errorbar(width = 0.1) +
  986. # Mark the 50-50 line
  987. geom_hline(yintercept = 0, linewidth = 1.5, linetype = 2) +
  988. # Do not stick the bars on the axis as this makes ugly overlaps
  989. coord_cartesian(expand = TRUE) +
  990. # Use color to indicate the pattern
  991. scale_fill_manual(values = patterns.colours) +
  992. # Set the scale to indicate relative preferences
  993. scale_y_continuous(
  994. limits = c(-0.5,0.5), breaks = c(-0.5, 0, 0.5),
  995. labels = c(always_inverted_name, "50-50", never_inverted_name)) +
  996. # Set labels
  997. labs(x = "Target", y = "% of responses without inversion", fill = "Pattern") +
  998. # Clean the theme
  999. theme(
  1000. # Remove background elements
  1001. panel.background = element_blank(), panel.grid = element_blank(),
  1002. # Remove everything but the stimuli from the x-axis
  1003. axis.line.x = element_blank(), axis.ticks.x = element_blank(), axis.title.x = element_blank(),
  1004. # Remove the ticks and title but restore the line of the y-axis
  1005. axis.line.y = element_line(colour = "black"), axis.ticks.y = element_blank(), axis.title.y = element_blank(),
  1006. # No need for a legend, fill colors are only evocative
  1007. legend.position = "none",
  1008. # Add some margins so that everything can be read
  1009. plot.margin = unit(c(1, 1, 0.1, 0.1), "cm")
  1010. ) +
  1011. ggtitle(label = pattern_name)
  1012. }
  1013. # Make a triple plot with inversion plots on each line
  1014. egg::ggarrange(
  1015. # One for correct vs.inverted errors
  1016. inversion.d %>% filter(pattern == "Normative") %>% inversion_plot("Correct or inverted responses", "Correct", "Inverted") + horizontal_ylab("Bias towards", vjust = 1.2) ,
  1017. # One for decimal confusion errors
  1018. inversion.d %>% filter(pattern == "Decimal") %>% inversion_plot("Decimal reading errors", "Numerator first", "Denominator first"),
  1019. # One for count of tenths errors
  1020. inversion.d %>% filter(pattern == "Counting_tenths") %>% inversion_plot("Count of tenths errors", "Count numerator", "Count denominator", use_quincunx_for_x_axis = T)
  1021. ) %>%
  1022. # Save
  1023. save_on_disk("Figure_6", path = "./figures/main/SVG/",
  1024. device = "svg", a4_fraction = c(1, 2/3), indicate_format = F)
  1025. ```
  1026. # Session ending details
  1027. ```{r saveFigs}
  1028. global_pptx %>% print(target = "./analysis_all_figs.pptx")
  1029. sessionInfo()
  1030. ```

paper_analysis.Rmd, no license · at the source

Overview

Authors: Daniela Valério1,2, Samuel Debray1,2, Alireza Karami1,2, Maxime Cauté1,2, Gravel Nicolás1,2, Stanislas Dehaene1,2
  1. Université Paris Saclay, INSERM, CEA, Cognitive Neuroimaging Unit, NeuroSpin Center, 91191, Gif-sur-Yvette, France
  2. Collège de France, PSL University, Paris, France
Institutions: Université Paris-Saclay (France); Inserm (France); Cognitive Neuroimaging Lab (France); Collège de France (France)
Dates: published online 25 March 2026
Type: Preprint
License: CC BY-NC-ND
Identifiers: DOI 10.64898/2026.03.24.713850 · OpenAlex W7140300973
Open access: green, a free copy (OpenAlex)
Preprint: osf.io/jqcna
Status: code verified (how the code is found and verified)
Methods: Connectivity, Statistics, Smoothing, state filtering, decompositions, fMRI & imaging, Machine learning
Keywords: Integers, Fractions, Mental number line, Parietal lobe, Intraparietal Sulcus, Semantic space, Mathematics, Numerotopic map
Topic: Cognitive and developmental aspects of mathematical skills (Developmental and Educational Psychology, Psychology), according to OpenAlex
Funding: European Research Council (101095866)
Citations: cited by 2 papers (Europe PMC); 107 references in the paper
Research resources: RRID:SCR_001362, 2008 RRID:SCR_004757

Abstract

The abstract is not reproduced here: the paper's license (CC BY-NC-ND) does not allow it. Read it in the paper, at the publisher or on Europe PMC.

Repository

Its files are read in the Code ↔ Paper reader above.

OSF 267s5

License: none: the authors keep all their rights
State: the link answers, verified on 30 September 2026
Evidence: files inventoried
Languages: R (3)
Size: 8 files, 3 scripts
Software Heritage: not checked
Found in: the references
Holds: 3 notebooks
Not found: README, license file, CITATION.cff, environment file, tests, continuous integration, documentation
Tools: ggplot2 (3 files), ggpubr (3 files), reshape2 (3 files), tidyverse (3 files), broom (2 files), rstatix (2 files)
Availability: 1 check, the latest on 30 September 2026: the link answers (HTTP 200)
  • 30 September 2026: the link answers (HTTP 200)
3 files
At the source: osf.io/267s5

Tracing map

Proposed by the machine: these links were found in the paper and verified at the source, without human review. The map will receive a Zenodo DOI once one of the paper's authors has validated it with their ORCID.

What the map holds:

  • 1 repository of the authors' code, each at its verified commit, with its license and how the link was found in the paper;
  • 3 scripts, each with its path and the digest of its content;
  • no match between paragraphs and code yet;
  • neither the text of the paper nor the code itself.

Its JSON (tracing-map.json) is deposited on Zenodo with its DOI once the map is validated.

Data

No dataset and no data link were found in the paper.

Versions

The history of this record: each version stored by the harvester or made by a correction of its authors or of the maintainers of its code, and what changed in its facts. The texts of the paper (its abstract, its availability statements) are not part of it; versions that changed only those are not listed.

Version 1, 30 September 2026: the first record

Recorded: type, journal, dates, 6 authors, 8 keywords, 1 funder, 101 references, 2 RRIDs.

Cite

This paper

Valério, D., Debray, S., Karami, A., Cauté, M., Nicolás, G., & Dehaene, S. (2026). A cortical semantic space integrating fractions and integers. bioRxiv (preprint). https://doi.org/10.64898/2026.03.24.713850

BibTeX

@article{valerio2026cortical,
author = {Valério, Daniela and Debray, Samuel and Karami, Alireza and Cauté, Maxime and Nicolás, Gravel and Dehaene, Stanislas},
title = {{A cortical semantic space integrating fractions and integers}},
journal = {bioRxiv (preprint)},
year = {2026},
month = mar,
publisher = {bioRxiv},
issn = {2692-8205},
doi = {10.64898/2026.03.24.713850},
url = {https://doi.org/10.64898/2026.03.24.713850}
}

RIS

TY - JOUR
AU - Valério, Daniela
AU - Debray, Samuel
AU - Karami, Alireza
AU - Cauté, Maxime
AU - Nicolás, Gravel
AU - Dehaene, Stanislas
TI - A cortical semantic space integrating fractions and integers
T2 - bioRxiv (preprint)
J2 - bioRxiv
PY - 2026
DA - 2026/03/25
SN - 2692-8205
PB - bioRxiv
DO - 10.64898/2026.03.24.713850
UR - https://doi.org/10.64898/2026.03.24.713850
ER -

CSL-JSON

{
"id": "10.64898/2026.03.24.713850",
"type": "article",
"title": "A cortical semantic space integrating fractions and integers",
"container-title": "bioRxiv (preprint)",
"author": [
{
"family": "Valério",
"given": "Daniela"
},
{
"family": "Debray",
"given": "Samuel"
},
{
"family": "Karami",
"given": "Alireza"
},
{
"family": "Cauté",
"given": "Maxime"
},
{
"family": "Nicolás",
"given": "Gravel"
},
{
"family": "Dehaene",
"given": "Stanislas"
}
],
"container-title-short": "bioRxiv",
"DOI": "10.64898/2026.03.24.713850",
"ISSN": "2692-8205",
"publisher": "bioRxiv",
"URL": "https://doi.org/10.64898/2026.03.24.713850",
"issued": {
"date-parts": [
[
2026,
3,
25
]
]
}
}

The tracing map gets a citation of its own once an author has validated it and it has a DOI.

Similar papers

The papers with a page that share the most with this one: the tools found in their code, their categories, datasets, cited references and authors, the rarest counting most.

[1] doi:10.1371/journal.pbio.3003820 [code]
A cortical semantic map integrates fractions and integers in the human brain.
Journal: PLoS biology
In common: broom, rstatix, reshape2, 3 other tools, 52 references
[2] doi:10.7554/elife.106464 [code]
A geometric shape regularity effect in the human brain.
Journal: eLife
In common: broom, ggplot2, tidyverse, 13 references
[3] doi:10.1038/s42003-025-08395-z [code]
Distinct neural representational geometries of numerosity in early visual and association regions across visual streams.
Journal: Communications biology
In common: 6 references, author Alireza Karami
[4] doi:10.1073/pnas.2213430120
Evaluating the impact of short educational videos on the cortical networks for mathematics.
Journal: Proceedings of the National Academy of Sciences of the United States of America
In common: 6 references, author Stanislas Dehaene
[5] doi:10.1073/pnas.2602515123 [code]
Initial organization and progressive expansion of the math-responsive brain network during the first school years.
Journal: Proceedings of the National Academy of Sciences of the United States of America
In common: 4 references, author Stanislas Dehaene
[6] doi:10.1523/eneuro.0065-24.2024 [code]
Gaze and Arrows: Does the Gaze-Following Patch in the Posterior Temporal Cortex Differentiate Social and Symbolic Spatial Cues?
Journal: eNeuro
In common: reshape2, ggpubr, ggplot2, 1 other tool, 8 references
[7] doi:10.1038/s41467-024-55459-5 [code]
Network structure influences the strength of learned neural representations.
Journal: Nature communications
In common: broom, tidyverse, 9 references
[8] doi:10.1002/hbm.70434 [code]
Network Neuroscience of Human Multitasking: Local Connections Matter.
Journal: Human brain mapping
In common: rstatix, ggpubr, tidyverse, 8 references
[9] doi:10.1162/imag_a_00424 [code]
Representation of navigational affordances and ego-motion in the occipital place area.
Journal: Imaging neuroscience (Cambridge, Mass.)
In common: rstatix, ggpubr, tidyverse, 8 references
[10] doi:10.1038/s41593-023-01283-x [code]
Hippocampal spatio-predictive cognitive maps adaptively guide reward generalization.
Journal: Nature neuroscience
In common: reshape2, ggplot2, 9 references

Contribute

The authors of this paper can claim it, correct its record and validate its tracing map, and the maintainers of its code (its owner, or a public member of its organization) correct what it says of their repository; anyone signed in can ask for its removal. Every request goes to OSCR's own machine, which answers it; your account page follows them.

Sign in with ORCID to claim this paper as one of its authors, correct its record or validate its tracing map: when the paper's metadata lists your ORCID iD, you are recognized at once. Maintainers of its code: sign in with GitHub, then claim the repository on your account page.

Add the badge to the code's README

OSCR: the badge of this paper

If you are an author or a maintainer of this paper's code, you can add this badge. It says what OSCR knows of the paper (its code, its validated tracing map) and links the code to this page. Paste one of these snippets under the README's title. Keep the two marks around it, so that the badge can be replaced in place and is never added twice. OSCR changes nothing in your repository.

Markdown
<!-- oscr-badge:start -->
[![OSCR](https://openscicode.org/badge/doi/10.64898/2026.03.24.713850.svg)](https://openscicode.org/badge/doi/10.64898/2026.03.24.713850)
<!-- oscr-badge:end -->
reStructuredText
.. oscr-badge:start

.. image:: https://openscicode.org/badge/doi/10.64898/2026.03.24.713850.svg
   :target: https://openscicode.org/badge/doi/10.64898/2026.03.24.713850
   :alt: OSCR

.. oscr-badge:end
HTML
<!-- oscr-badge:start -->
<a href="https://openscicode.org/badge/doi/10.64898/2026.03.24.713850"><img src="https://openscicode.org/badge/doi/10.64898/2026.03.24.713850.svg" alt="OSCR"></a>
<!-- oscr-badge:end -->

If you choose to, you can have the badge kept up to date for you, with oscr badge --write from the command line or with the GitHub Action in your repository's workflows. Every other form and option (a card, AsciiDoc, LaTeX, shields.io) is on the badge page.

Request its removal

To ask OSCR to remove this record, the copies of its authors' scripts or its tracing map, use the removal request page: signed in, you say who you are, what to remove and why, then review and confirm the request. Published rules decide every request (how).

Discussion, reproductions, activity

Discussion: code errors and mismatches between the code and the paper about this paper and its code, from readers and its authors, beside the paper and its tracing map.

Report a mismatch between the code and the paper · Report a code error (signed in)

Reproductions: reports from readers who ran the authors' code and did not get the paper's result, what they ran, with which environment, commit and data.

Report a reproduction failure (signed in)

Activity: what happens around this paper: new versions of its record, its map's validation, discussions and reproductions. It opens with sign-in.