A cortical semantic space integrating fractions and integers
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
- ---
- title: Analyses ligne numérique CSEN - Expérience 1
- author:
- - Maxime Cauté
- lang: fr
- output: rmdformats::readthedown
- fig-dpi: 160
- ---
- ```{r settings, echo = FALSE, message = FALSE}
- knitr::opts_chunk$set(echo = FALSE, warning = FALSE, message = FALSE)
- # Package manager
- if(!require("pacman")) install.packages("pacman")
- # GGplot-friendly library for IRT.
- if(!require("ggmirt")) devtools::install_github("masurp/ggmirt")
- pacman::p_load(tidyr, dplyr, tibble, magrittr, # Tidy scripts
- apa, # Report stats in APA format
- mirt, ggmirt, # IRT analysis
- egg, # Very neat ggarrange
- ggplot2,
- devtools,
- ggprism, grid, ggpubr, # Plot legends
- purrr,
- officer, rvg, # Outputing pptx
- pandoc, # HTML output
- stringr, #String manipulations
- #cooccur,
- reshape, # Matrix manipulation and stuff
- install = T)
- ```
- ```{r}
- # All helpers in a dedicated file
- source("all_helpers.R")
- # Parameters for printing
- PT_TO_PIXEL = 16/12
- FAMILY = "NimbusSan"
- # Add a theme that specifies the size
- theme_text = function(size = 16, title.size = 18, family = FAMILY, scaling_factor = 1){
- theme(text = element_text(family = family, size = size * scaling_factor),
- axis.text = element_text(family = family, size = size * scaling_factor),
- legend.text = element_text(family = family, size = size * scaling_factor),
- axis.title = element_text(family = family, size = title.size * scaling_factor),
- legend.title = element_text(family = family, size = title.size * scaling_factor),
- strip.text = element_text(family = family, size = title.size * scaling_factor),
- plot.subtitle = element_text(family = family, size = title.size * scaling_factor),
- plot.title = element_text(family = family, size = title.size * scaling_factor)) %>%
- return
- }
- # Use PT_TO_PIXEL to save the text as the correct size in pt in svg (svg saves raw text.size as px)
- # If you want to export as knit, you may want to change this.
- theme_set(theme_get() %+replace% theme_text(scaling_factor = PT_TO_PIXEL))
- ```
- # Data loading
- Load the data, only drop the column index.
- ```{r dataLoading}
- d = read.csv("./data/cleaned_all_years.csv") %>% select(-X)
- ```
- ### Performance on integers and decimals
- Only keep data relevant to the experiment: data from 6th graders with no tutorial.
- ```{r dataFiltering}
- d %<>%
- # Remove the tutorial trial
- filter(!is.tutorial) %>%
- # Only keep 6th graders for Exp. 1
- filter(class == "6ème") %>%
- # Only keep data from the second line, on integers (6), decimals (7), and fractions (9)
- filter(stimtype %in% c(6, 7, 9))
- ```
- Check that we have what we expect: average error rates with examples for each type of stimulus
- ```{r performance on integers and decimals}
- d %>%
- # Compute the error rate for each type of stimulus, for each participant. Take the first stimulus as an example.
- group_by(subj, stimtype) %>% summarise(
- error_rate = mean(!is.correct), sample_stimulus = stimulus %>% first, .groups = "drop") %>%
- # Average across targets
- group_by(stimtype) %>% summarise(
- error_rate.se = se(error_rate), error_rate = mean(error_rate),
- sample_stimulus = sample_stimulus %>% first) %>%
- # Round error rates at 3 decimals, and error rate at 4. Display both as %.
- mutate(error_rate = error_rate %>% (scales::percent_format(accuracy = 0.1)),
- error_rate.se = error_rate.se %>% (scales::percent_format(accuracy = 0.01))) %>%
- # Only display relevent informations
- select(stimtype, sample_stimulus, error_rate, error_rate.se)
- ```
- As we can see, performance on integers is correct, somewhat so for decimals. Fractions really stand out!
- ### Fractions
- ```{r fractionFilter}
- # Now that we looked at error rates across all targets, drop everything but fractions.
- d %<>% filter(stimtype == 9) %>% select(-stimtype)
- ```
- ```{r dataFormattingHelpers}
- # Create pretty names for our different categories of fractions
- fraction_categories = c("Equivalent to 1/2", "Integer result", "n / 1", "Tenths")
- # Create a function that categorizes fractions...
- categorize_fractions = function(numerator, denominator){
- factor(
- # ... if the denominator is ten.
- if_else(denominator == 10, fraction_categories[4],
- # ... if the denominator is 1
- if_else(denominator == 1, fraction_categories[3],
- # ... if the fraction is equivalent to 1/2
- if_else(round(numerator / denominator, 1) == 0.5, fraction_categories[1],
- # ... or else, then the fraction is equivalent to an integer
- fraction_categories[2]))),
- # Make this a factor with the desired order.
- levels = fraction_categories) %>%
- return
- }
- # Define a helper function that counts responses with an entry for all possible responses,
- # including those that were never answered
- # This is not done by count().
- count_possible_responses = function(d, ...){
- d %>%
- # Count responses per stimulus and position and whatever additional argument is passed.
- count(stimulus, actual_response, ...) %>%
- # Right join with...
- right_join(
- # ... all the target stimuli (and whatever is also grouping)...
- d %>% select(stimulus, ...) %>% unique %>%
- # ... and all 51 positions on the number line
- merge(tibble(position = seq(0, 5, 0.1) %>% round(1))) %>%
- # And now order that to make it tidy
- arrange(stimulus),
- # Join by the specified columns, mapping actual response positions to the possible positions
- by = d %>% select(stimulus, ...) %>% colnames %>% c("actual_response" = "position")
- ) %>%
- # If some stimulus was added with the right join, then it has no responses.
- mutate(n = n %>% replace_na(0)) %>%
- # Make everything easier to read.
- arrange(stimulus, actual_response) %>%
- return
- }
- # This loads a function that can compute_fractions_predictions, which is shared across analysis files.
- source("src/fractions_predictions.R")
- # Compute the predictions for our actual stimuli.
- fractions_predictions.d = d %>%
- # Select the relevant stimuli and make them unique
- select(stimulus) %>% unique %>%
- # Compute the predictions.
- compute_fractions_predictions
- ```
- ```{r dataFormatting}
- # Format the data to help later computations
- d %<>%
- # Get the exact actual response to avoid floating-point errors.
- mutate(actual_response = actual.response %>% round(1), .keep = "unused") %>%
- # Extract the components of the stimuli
- mutate(numerator = stimulus %>% str_split_i(" / ", 1) %>% as.numeric,
- denominator = stimulus %>% str_split_i(" / ", 1) %>% as.numeric) %>%
- # Compute the fraction category from the components
- mutate(category = categorize_fractions(numerator, denominator)) %>%
- # Adopt a snake_case convention.
- dplyr::rename(is_correct = is.correct)
- ```
- # Demographics
- Number of girls
- ```{r}
- # Read demographics data
- demographics.d = read.csv("./demographics/demographics_all_years.csv")
- # Compute the number of each sex.
- demographics.d %>%
- # Only keep the participants whose data we care about
- filter(subj %in% d$subj) %>%
- # Count by sex
- count(sex)
- ```
- # Poor and varied performance
- ## Global error rate
- How many errors do children do on average?
- ```{r global error rate}
- # Compute the error rate averaged across subjects
- d %>%
- # Compute each subject's error rate
- group_by(subj) %>% summarise(error_rate = mean(!is_correct), .groups = "drop") %>%
- # And now compute the average error rate across subjects, as well as standard error.
- summarise(global_error_rate.se = se(error_rate), global_error_rate = mean(error_rate), .groups = "drop") %>%
- (function(d){
- cat("The average error rate in 6ème is ",
- d$global_error_rate[[1]] %>% (scales::percent_format(accuracy = 0.1)),
- " (se=",
- d$global_error_rate.se[[1]] %>% (scales::percent_format(accuracy = 0.01)), ").", sep = "")
- })
- ```
- ## Error rates per stimulus
- Are some stimuli easier than others?
- ```{r error rate per stimulus, fig.width = 8.27/2, fig.height = 11.69 / 2}
- # Compute error rates per stimulus
- error_rates.d = d %>%
- # Group by stimulus and category...
- group_by(stimulus, category) %>%
- # ...and compute error rate and half binomial confidence interval (since we work with binary data)
- summarise(error_rate = mean(!is_correct), error_rate.half_ci = half_binomial_ci(error_rate, n()), .groups = "drop") %>%
- # Order by error rate to make everything easier to read.
- arrange(-error_rate)
- ```
- Compute the maximum, minimum and 1/2's error rates
- ```{r remarkableStimulusErrorRates}
- error_rates.d %>%
- select(stimulus, error_rate) %>%
- filter(error_rate == max(error_rate)) %>%
- (function(d){
- cat("Maximum error rate:", (scales::percent_format)(accuracy = 2)(d$error_rate[[1]]),
- "for fraction", d$stimulus[[1]])
- cat(".\n")
- })
- error_rates.d %>%
- select(stimulus, error_rate) %>%
- filter(error_rate == min(error_rate)) %>%
- (function(d){
- cat("Minimum error rate:", (scales::percent_format)(accuracy = 2)(d$error_rate[[1]]),
- "for fraction", d$stimulus[[1]])
- cat(".\n")
- })
- error_rates.d %>%
- select(stimulus, error_rate) %>%
- filter(stimulus == "1 / 2") %>%
- (function(d){
- cat("Error rate for 1 / 2:", (scales::percent_format)(accuracy = 2)(d$error_rate[[1]]))
- cat(".\n")
- })
- ```
- Significance of differences
- ```{r error rate per stimulus chi square test and anova}
- d %>%
- # Select each response as an entry with the stimulus and response correctness
- select(stimulus, is_correct) %>%
- # Turn this into a table...
- table %>%
- # On which we perform a chi-square test
- chisq.test %>%
- # Print it according to APA norms
- apa(print = T)
- ```
- ## Error rates per category
- ```{r error rates per category}
- # Compute the average error rates for each category.
- d %>%
- # Compute average error rate per target
- # (no need to average by participant since a target was only seen once at most by a given participant)
- group_by(stimulus, category) %>% summarise(error_rate = mean(!is_correct), .groups = "drop") %>%
- # Now average across stimuli
- group_by(category) %>% summarise(error_rate = mean(error_rate), .groups = "drop") %>%
- # Order by error rate to make it easier to read.
- arrange(-error_rate)
- ```
- Are differences significant ? We use an anova on error rate by stimulus here, because we would have dependency in the data otherwise.
- 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.
- ```{r errorRatesPerCategory}
- # Not happy with this since participants have not seen the same amount of stimuli in each category
- # You can't group by subject either because you then have a dependency on stimulus
- d %>%
- # For each target, compute error rate
- group_by(stimulus, category) %>% summarise(error_rate = mean(!is_correct), .groups = "drop") %>%
- # Run an anova by category
- rstatix::anova_test(formula = error_rate ~ category) %>%
- # Print the results in APA-friendly format
- rstatix_anova_to_APA()
- ```
- # Variations in difficulties - IRT
- ## IRT difficulty
- ```{r irtDifficulty}
- irt_2PL_model.plci = d %>%
- # Change correctness to numeric value, which is required for IRT analysis
- mutate(is_correct = as.numeric(is_correct)) %>%
- # Only keep the relevant stuff for IRT: subject, target, and outcome of the question
- select(subj, stimulus, is_correct) %>%
- # Format everything with one column per target to fit IRT format requirement.
- pivot_wider(
- names_from = stimulus,
- values_from = is_correct
- ) %>%
- # Drop the subject model, as IRT does not care about identifiers.
- select(-subj) %>%
- # Fit IRT model
- mirt(itemtype = "2PL", technical = list(NCYCLES = 10000)) %>%
- # Get 95% CI - this may be long
- PLCI.mirt %>%
- # The reported d is easiness, so change it since we want difficulty in order to match error rates
- # https://groups.google.com/g/mirt-package/c/RwGmO_fGi2c
- mutate(parnam = if_else(parnam == "d", "difficulty", parnam),
- value = if_else(parnam == "difficulty", -value, value),
- lower_2.5 = if_else(parnam == "difficulty", -lower_2.5, lower_2.5),
- upper_97.5 = if_else(parnam == "difficulty", -upper_97.5, upper_97.5))
- ```
- What is the link between difficulty and error rate
- ```{r irtDifficultyAndErrorRateLM}
- irt_2PL_model.plci %>%
- # Only care about difficulty, not discrimination
- filter(parnam == "difficulty") %>%
- # Only look at the target and the result
- select(Item, value) %>% dplyr::rename(stimulus = Item, difficulty = value) %>%
- # Add the average error rate for each target
- inner_join(d %>% group_by(stimulus) %>% summarise(error_rate = mean(!is_correct), .groups = "drop"), by = c("stimulus")) %>%
- # Compute the linear regression.
- lm(formula = error_rate ~ difficulty) %>%
- # Print the result according to APA norms
- summary %>% regression_summary_to_APA
- ```
- ### Error rate & IRT figure
- ### Figure 2
- ```{r errorRateAndDifficultyByStimulus, fig.width = 8.27, fig.height = 11.69/2}
- # Make the plot for error rates
- error_rates.pl = error_rates.d %>%
- # Plot error rate by stimulus (ordered by error rate) and colored by category
- ggplot(aes(x = error_rate, y = reorder(stimulus, error_rate), fill = category,
- xmin = error_rate, xmax = error_rate + error_rate.half_ci)) +
- # Plot the error rate as bars...
- geom_bar(stat = "identity") +
- # ... with standard error
- geom_errorbar(width = 0) +
- # Ensure that similar ylims and expansion are used on the two plots!
- coord_cartesian(xlim = c(0.6, 1.001), ylim = c(0.5, d$stimulus %>% unique %>% length + 0.5), expand = F) +
- # Plot error rate as a percentage
- scale_x_continuous(labels = scales::percent_format()) +
- # Define axis labels
- labs(y = "Target", x = "Error rate", fill = "Category") +
- # Make a clean plot
- theme(axis.title.y = element_blank(), panel.background = element_blank(), panel.grid = element_blank(),
- # Add some lines to delimit everything
- axis.line = element_line(colour = "black"), panel.grid.major.x = element_line(colour = "gray", linetype = 1),
- # Use the same margin on the two plots!
- plot.margin = margin(0, 1,0,0.1, unit = "cm"),
- # No use for a legend in this plot.
- legend.position = "none")
- # Make the difficulty plot
- difficulty.pl = irt_2PL_model.plci %>%
- # We only care about difficulty
- filter(parnam == "difficulty") %>%
- # Keep relevant parameters (target, value and 95% CI)
- select(Item, value, lower_2.5, upper_97.5) %>%
- # Add error rates so that target order matches the error rate plot.
- inner_join(d %>% group_by(stimulus, category) %>% summarise(error_rate = mean(!is_correct), .groups = "drop"), by = c("Item" = "stimulus")) %>%
- # Plot difficulty by item, with items ordered by error rates just like the others.
- ggplot(aes(y = reorder(Item, error_rate), x = value, fill = category,
- xmin = lower_2.5, xmax = upper_97.5)) +
- # Add lines at hard and very hard boundaries.
- geom_vline(xintercept = c(0.5, 2), colour = "red", linetype = 2) +
- # Add errorbars
- geom_errorbar(width = 0.5) +
- # Add points, with a triangle shape like usual IRT plots.
- geom_point(size = 5, shape = 22) +
- # Make breaks for difficulty every 0.5
- scale_x_continuous(breaks = c(0.5, seq(2,8, 2)), labels = function(x){signif(x, 0)}) +
- # Ensure that similar ylims and expansion are used on the two plots!
- coord_cartesian(xlim = c(0, 8), ylim = c(0.5, d$stimulus %>% unique %>% length + 0.5), expand = F) +
- # Add axis labels
- labs(x = "Difficulty", y = "Target", fill = "Category") +
- # Clean the plot
- theme(axis.title.y = element_blank(), panel.background = element_blank(), panel.grid = element_blank(),
- # Add some lines to help readability
- axis.line = element_line(colour = "black"), panel.grid.major.x = element_line(colour = "gray"),
- # Use some margin to make axes legible - use the same margin on the two plots!
- plot.margin = margin(t = 0, r = 1, b = 0, l = 0.1, unit = "cm"),
- # Place the legend as inset
- legend.position = c(0.7, 0.15), legend.background = element_rect(colour = "black"),
- # Remove legend grey background
- legend.key = element_blank(),
- # Just set usual text (this is only meant for tweaking)
- legend.text = element_text(hjust = 0),
- # Black frame
- legend.margin = margin(t = 0.3, r = 1.1, b = 0.3, l = 0.3, "cm"),
- # Space between entries
- legend.key.height = unit(1, "cm")
- ) +
- # Specifiy the size of fill indications in the legend.
- guides(fill = guide_legend(override.aes = list(size = 7)))
- # Create a two-plots figure and save it
- ggarrange(error_rates.pl, difficulty.pl, ncol = 2) %>%
- save_on_disk("Figure_2", path = "./figures/main/", a4_fraction = c(1, 1/2))
- ```
- # Non-random errors
- ## Precision
- ```{r precision}
- # Create a function that finds an print the proportion of responses fallling within a given threshold of the correct answer.
- (function(threshold){
- d %>%
- # Drop non-responses and wrong responses????
- filter(!actual_response %>% is.na, !is_correct) %>%
- # Only keep relevant data
- select(stimulus, response, actual_response) %>%
- # Compute the distance to the response: if it is below the threshold, call it a close response.
- mutate(response_distance = abs(actual_response - response),
- is_close_response = response_distance <= threshold) %>%
- # For each target, compute the frequency of close response.
- group_by(stimulus) %>% summarise(close_response_rate = mean(is_close_response),.groups = "drop") %>%
- # Compute the average rate across targets.
- summarise(close_response_rate.se = se(close_response_rate), close_response_rate = mean(close_response_rate)) %>%
- # Print the result
- (function(d){cat("The proportion of responses falling within ", threshold, " of the correct answer is ",
- d$close_response_rate[[1]] %>% (scales::percent_format(accuracy=0.1)), "%",
- " (se = ", d$close_response_rate.se[[1]] %>% (scales::percent_format(accuracy = 0.01)), "%).",
- sep = "")
- })
- }) (0.25)
- ```
- ## Imbalanced distributions
- Are there stimulus where the response distribution is not uniform?
- ```{r distribution of errors per stimulus}
- d %>%
- # Count possible responses, it happens that each response that was never given is incorrect
- count_possible_responses(is_correct) %>% mutate(is_correct = is_correct %>% replace_na(F)) %>%
- # Only consider erroneous responses: drop non-responses and correct responses
- filter(!actual_response %>% is.na, !is_correct) %>%
- # Only care about the number of responses for each position and target -- the target itself is not relevant
- select(stimulus, n) %>%
- # Perform a chi-square test on each target and put the results in adequate columns.
- group_by(stimulus) %>% summarise(test = chisq.test(n) %>% (broom::tidy), .groups = "drop") %>% unnest %>%
- # Rename columns (and drop useless method report)
- dplyr::rename(X2 = statistic, df = parameter, p = p.value) %>% select(-method) %>%
- # FDR-correction for p-values, and put them in nice APA format
- mutate(p = p %>% p.adjust("fdr") %>% p_value_to_APA()) %>%
- # Order target from most significative test to the least significative one.
- arrange(X2) %>%
- # Round as per APA format
- mutate(X2 = X2 %>% flexible_round(2)) %>%
- # Report results in a human-friendly format.
- mutate(stats = str_c("\\Chi2(", df, ") = ", X2, ", ", p), .keep = "unused")
- ```
- Can we explain the imbalance in responses based on integers?
- ```{r error distribution without integer}
- d %>%
- # Count possible responses, it happens that each response that was never given is incorrect
- count_possible_responses(is_correct) %>% mutate(is_correct = is_correct %>% replace_na(F)) %>%
- # Identify integer responses
- mutate(is_integer = actual_response %>% round == actual_response) %>%
- # Only consider erroneous responses: drop non-responses, correct responses, and integer responses
- filter(!actual_response %>% is.na, !is_correct, !is_integer) %>%
- # For each target, perform a chi-square test and put the results in adequate columns.
- group_by(stimulus) %>% summarise(test = chisq.test(n) %>% (broom::tidy), .groups = "drop") %>% unnest %>%
- # Rename columns (and drop useless method report)
- dplyr::rename(X2 = statistic, df = parameter, p = p.value) %>% select(-method) %>%
- # FDR-correction for p-values, and put them in nice APA format
- mutate(p = p %>% p.adjust("fdr") %>% p_value_to_APA()) %>%
- # Order target from most significative test to the least significative one.
- arrange(X2) %>%
- # Round as per APA format
- mutate(X2 = X2 %>% flexible_round(2)) %>%
- # Report results in a human-friendly format.
- mutate(stats = str_c("\\Chi2(", df, ") = ", X2, ", ", p), .keep = "unused")
- ```
- 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).
- To what extent are error positions falling on a restricted set of responses?
- ```{r error position stacking}
- d %>%
- # Drop non-responses and correct responses
- filter(!actual_response %>% is.na, !is_correct) %>%
- # Count how many times each response was given for each target.
- count(stimulus, actual_response) %>%
- # Perform this analysis per target
- group_by(stimulus) %>%
- # Turn the count into a proportion per target.
- mutate(proportion = n / sum(n)) %>%
- # Arrange responses by decreasing proportion: most given responses are on top...
- arrange(stimulus, -proportion) %>%
- # And now make a cumulated sum of the proportions: each sum covers the most given responses since we ordered them above!
- mutate(cumsummed_proportion = cumsum(proportion),
- # Also count the number of positions included in the cumsum.
- actual_response_index = 1:n()) %>%
- # Only keep the cumsums after which the 50% of responses threshold was hit.
- filter(cumsummed_proportion > .50) %>%
- # 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)
- filter(actual_response_index == min(actual_response_index)) %>%
- # Ungroup, we now consider results across targets again.
- ungroup %>%
- # Only keep relevant info: targets, and the number of positions. required to reach 50% of responses.
- select(stimulus, actual_response_index) %>%
- # Compute the average number of responses required, and print the result in a nice
- summarise(mean_index = mean(actual_response_index)) %>%
- (function(d){cat("On average, half the responses fall on", flexible_round(d$mean_index, 2), "positions.")})
- ```
- # Inversion
- ## Four targets
- 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)
- ```{r four targets example, fig.width = 8.27, fig.height = 11.69/2}
- (d %>%
- # Only keep our targets of interest
- filter(stimulus %in% c("1 / 2", "3 / 6", "2 / 1", "6 / 3"),
- # Drop non-responses.
- !actual_response %>% is.na) %>%
- # Count the number of responses per target (also keep some info about the target like components and whether the answer is correct or not)
- group_by(stimulus, numerator, denominator, response) %>% count(actual_response, is_correct) %>%
- # Turn the number of responses into a proportion per target, and count the total number of responses per target.
- group_by(stimulus) %>% mutate(proportion = n / sum(n), sample_size_label = str_c("n = ", sum(n))) %>%
- # Add a some infos about the target as label for later plotting.
- # We only add a single label per target to avoid cluttering the plot, on the correct response for convenience
- # (Note that here we have at least one correct responses per target, but this line may fail if there is none.)
- mutate(sample_size_label = if_else(is_correct, sample_size_label, NA),
- numerator_label = if_else(is_correct, numerator, NA),
- denominator_label = if_else(is_correct, denominator, NA),
- response_label_position = if_else(is_correct, response, NA)) %>%
- ungroup %>%
- # Match responses with predictions from our patterns to mark the responses on the plot
- left_join(
- # If several patterns yield the same prediction, only keep the first one as it is just illustrative
- fractions_predictions.d %>% group_by(stimulus, prediction) %>% filter(1:n() == 1) %>%
- # Separate the 'normative' pattern into correct (without inversion3) and inverted
- mutate(pattern = if_else(pattern == "Normative", if_else(inverted, "Inverted", "Correct"), pattern)),
- # Join the predictions with actual responses
- by = c("stimulus", "actual_response" = "prediction")) %>%
- # Order the stimuli neatly for the facetted display
- mutate(stimulus = stimulus %>% factor(c("1 / 2", "3 / 6", "2 / 1", "6 / 3"))) %>%
- # Order the patterns neatly for the legend
- mutate(pattern = pattern %>% factor(levels =
- patterns.colours[patterns.colours %>% names != "Normative"] %>% names %>%
- # Add blanks to correctly shape the columns - go in decreasing order to avoid shifting indices
- append(c("BLANK3", "BLANK2"), after = 5) %>% append("BLANK1", after = 3)
- )) %>%
- # Plot the proportion of responses per position, filling the bars matching specific patterns
- ggplot(aes(x = actual_response, y = proportion, fill = pattern)) +
- # Add bars to indicate the proportion of resopnses.
- geom_bar(stat = "identity") +
- # 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.
- # Write the sample size in the top right corner
- geom_text(aes(label = sample_size_label), x = 4.5, y = .36, size = 15 * PT_TO_PIXEL / .pt, family = FAMILY) +
- # Write the target fraction above the response (the correct one, since all others have NAs)
- geom_text(aes(label = numerator_label, x = response), y = .38, size = 18 * PT_TO_PIXEL / .pt, family = FAMILY) +
- geom_text(aes(label = denominator_label, x = response), y = .34, size = 18 * PT_TO_PIXEL / .pt, family = FAMILY) +
- # Write the vinculum of the fraction
- geom_segment(aes(x = response_label_position - 0.1, xend = response_label_position + 0.1),
- y = .36, yend = .36, linewidth = 0.5) +
- # Indicate the correct response with an arrow poiting to it.
- ## Body
- geom_segment(aes(x = response_label_position, xend = response_label_position,
- y = proportion + 0.02),
- yend = .31) +
- ## Head
- geom_segment(aes(x = response_label_position, xend = response_label_position - 0.1,
- y = proportion + 0.02, yend = proportion + 0.04)) +
- geom_segment(aes(x = response_label_position, xend = response_label_position + 0.1,
- y = proportion + 0.02, yend = proportion + 0.04)) +
- # Create one subplot for each of the four targets, in a 2x2 display
- facet_wrap(~stimulus, nrow = 2, scales = "free_x") +
- # Indicate axes labels
- labs(x = "Responded position", y = "% of responses", fill = "Response type") +
- # Set the limits of the y axis (determined by hand) and display % of responses as actual %s.
- scale_y_continuous(limits = c(0, 0.4), labels = scales::percent_format()) +
- # Clean the panel to make it easier to read.
- theme(panel.background = element_blank(), panel.grid = element_blank(), panel.spacing = unit(0.5, "cm"),
- # Add some margins to space everything.
- plot.margin = margin(t = 0.6, r = 0.5, b = 0.5, l = 0.5, "cm"),
- # Remove the strip from facet plot
- strip.background = element_blank(), strip.text = element_blank(),
- # Make the titles
- #axis.title = element_text(),
- axis.title.x = element_text(hjust = 0.14, vjust = 0),
- axis.line = element_line(colour = "black"), panel.grid.major.y = element_line(colour = "gray", linetype = 1),
- legend.position = "bottom",
- # Remove grey background from keys
- legend.key = element_blank(), legend.title = element_text(vjust = 0.75)) +
- # legend.position = "none") +
- # Add a horizontal y axis legend - small hacky stuff with subtitle
- theme(axis.title.y = element_blank(),
- plot.title = element_blank(), plot.subtitle = element_text(hjust = -0.08, vjust = 4)) +
- ggtitle("", subtitle = "% of responses") +
- scale_x_decimal_number_line(expand = F) +
- guides(fill = guide_legend(ncol = 3), override.aes = aes(width = 1)) +
- # This is a very hacky way to get the shape of the legend right
- scale_fill_manual(values = c(patterns.colours, "BLANK1" = "white", "BLANK2" = "white", "BLANK3" = "white"),
- labels = Vectorize(function(p){
- if(p == "BLANK1") return("to the line")
- else if(!p %in% names(pretty_pattern_names)) return("")
- else pretty_pattern_names[[p]] %>% str_remove(" to the line")}),
- drop = FALSE
- )
- ) %>%
- save_on_disk("Figure_3", path = "./figures/main/SVG/", device = "svg", a4_fraction = c(1, .60), indicate_format = F)
- ```
- ## Pairwise confusion
- Compute the similarity between distributions for pairs of inverted fractions.
- ```{r fig.width= 8.27/2, fig.height = 11.69/2}
- # Compute the list of pairs of inverse fractions, ordered to make plots using it easier to read.
- ordered_inverse_targets_pairs = d %>%
- # Only consider the targets and their components
- select(stimulus, numerator, denominator) %>% unique %>%
- # Sort the components so that pairs of inverse fractions have the same components in the same order
- mutate(smaller_component = pmin(numerator, denominator),
- greater_component = pmax(numerator, denominator)) %>%
- # Only keep fractions where you can build a pair with same components (i.e., inverted fractions)
- group_by(smaller_component, greater_component) %>% filter(n() == 2) %>%
- # Reorder everything pairwise to have an order for future plots
- arrange(greater_component, smaller_component, numerator) %$%
- # Keep the list of targets
- stimulus
- # Compute the correlation between distributions -- to show that it is greater for inverted fractions
- inverted_pairs_distributions_correlations = d %>%
- # Count the number of responses per position for each target, including possible positions without any response, since we care about distributions.
- count_possible_responses(numerator, denominator) %>%
- # Only keep stimuli for which an inverse fraction was presented
- filter(stimulus %in% ordered_inverse_targets_pairs) %>%
- # Keep relevant infos: target, response position, number of responses.
- select(stimulus, actual_response, n) %>%
- # For each position, count the number of responses that fell on it per target
- group_by(stimulus) %>% pivot_wider(names_from = stimulus, values_from = n) %>%
- # Now drop the exact positions, since only care about the distributions
- select(-actual_response) %>%
- # Compute the correlation between each line of the matrix...
- cor %>%
- # ...and report it with one line per pair.
- melt %>%
- # Rename the columns and make the targets into factor for a consistent order.
- mutate(target1 = X1 %>% factor(levels = ordered_inverse_targets_pairs),
- target2 = X2 %>% factor(levels = ordered_inverse_targets_pairs),
- pearson_r = value,
- .keep = "unused")
- # Make a plot
- (inverted_pairs_distributions_correlations %>%
- # Identify target 1 as x_label
- dplyr::rename(x_label = target1) %>%
- # Reinject numerators and denominators to make computations with them on x label.
- inner_join(d %>% select(stimulus, numerator, denominator) %>% unique,
- by = c("x_label" = "stimulus")) %>%
- # Place fractions in a pair on alternating lines, by adding a line return for those > 1
- mutate(x_label = str_c(if_else(numerator > denominator, "\n", ""), x_label %>% str_remove_all(" "))) %>%
- # Plot the correlation between the first target (x-axis) and second target (y-axis)
- ggplot(aes(x = reorder(x_label, target1 %>% as.numeric), y = target2, fill = pearson_r)) +
- # Tile with pearson correlation between distributions
- geom_tile(color = NA) +
- # Ensure that the matrix is a square
- coord_fixed() +
- # Set a nicer scale for filling the tiles
- scale_fill_viridis_c(option = "magma") +
- # Set labels
- labs(x = "Target 1", y = "Target 2", fill = "Pearson's r") +
- # Clean the plot
- theme(panel.background = element_blank(), panel.grid = element_blank(),
- # Drop the axes ticks and title
- axis.ticks = element_blank(), axis.title = element_blank(),
- # Set the legend
- legend.position = "top", legend.key.width = unit(1.5, "cm"), legend.title = element_text(vjust = 1))
- ) %>%
- # Save
- save_on_disk("Figure_4", path = "./figures/main/SVG/", a4_fraction = c(1/2, 1/2), indicate_format = F)
- ```
- Is the correlation stronger within pairs (big squares on the diagonal) than across?
- ```{r correlationForInvertedPairsSummary}
- # Compute the correlation for each non-ordered pair
- non_ordered_pairs_distributions_correlations = inverted_pairs_distributions_correlations %>%
- # Add back the numerator and denominator of target 1
- inner_join(d %>% select(stimulus, numerator, denominator) %>% unique %>% dplyr::rename(target1 = stimulus, numerator1 = numerator, denominator1 = denominator),
- by = c("target1")) %>%
- # Add back the numerator and denominator of target 1
- inner_join(d %>% select(stimulus, numerator, denominator) %>% unique %>% dplyr::rename(target2 = stimulus, numerator2 = numerator, denominator2 = denominator),
- by = c("target2")) %>%
- # We only want to consider non-ordered pairs, so only keep those following the lexicographic order
- filter(numerator1 < numerator2
- | numerator1 == numerator2 & denominator1 < denominator2) %>%
- # Flag inverted pairs
- mutate(is_inverted_pair = numerator1 == denominator2 & denominator1 == numerator2, .keep = "unused")
- non_ordered_pairs_distributions_correlations %>%
- # Compute the average correlation within and across inverted pairs
- group_by(is_inverted_pair) %>% summarise(
- pearson_r.se = se(pearson_r), pearson_r = mean(pearson_r), .groups = "drop") %>%
- # Report the results
- select(is_inverted_pair, pearson_r, pearson_r.se)
- ```
- ```{r correlationForInvertedPairsStats}
- # Look how the inverted pairs rank in terms of correlations compared to others
- non_ordered_pairs_distributions_correlations %>% arrange(-pearson_r)
- ```
- There is only one non-inverted pair for which the correlation is better than a single inverted pair
- Since we have 6 relevant pairs (those of inverted pairs) and 66 pairs in total, there are 6C66 possible orderings (~90,858,768)
- 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
- # Global error patterns
- Now evaluate the patterns across all targets
- ```{r total prediction}
- d %>%
- # Remove non-responses
- filter(!actual_response %>% is.na, !is_correct) %>%
- # Count reponses per stimulus and position
- count(stimulus, actual_response) %>%
- # Convert the count into a proportion to allow for comparison across stimulus
- group_by(stimulus) %>% mutate(proportion = n/sum(n)) %>% ungroup %>%
- # Only keep stimulus-response pairs that are also in the predictions df.
- # Drop the patterns themselves as we don't care about them
- inner_join(fractions_predictions.d %>% select(stimulus, prediction) %>% unique,
- by = c("stimulus", "actual_response" = "prediction")) %>%
- # Since we have disjoint entries in the set, we can compute the proportion explained per stimulus as the sum.
- group_by(stimulus) %>% summarise(proportion_explained = sum(proportion), .groups = "drop") %>%
- # Now compute the average proportion across all stimuli
- mutate(mean_proportion_explained = mean(proportion_explained)) %>%
- # And order them from the least explained.
- arrange(proportion_explained)
- ```
- Compute the proportion of non-ambiguously explained responses
- ```{r unambiguously predicted}
- d %>%
- # Ignore non-responses
- filter(!actual_response %>% is.na) %>%
- # Count each response on each stimulus
- count_possible_responses() %>%
- # Turn count into proportion to allow for comparison across stimuli
- group_by(stimulus) %>% mutate(proportion = n/sum(n)) %>% ungroup %>%
- # Match responses to pattern prediction, but only when not ambiguous!
- inner_join(fractions_predictions.d %>% filter(!ambiguous),
- by = c("stimulus", "actual_response" = "prediction")) %>%
- # Compute the proportion of responses explained by a pattern across stimuli
- group_by(pattern, inverted) %>% summarise(proportion.se = se(proportion), proportion = mean(proportion), .groups = "drop") %>%
- # Reorder the columns...
- select(pattern, inverted, proportion, proportion.se) %>%
- # ...and round to ease readability
- mutate(proportion = scales::label_percent(accuracy = 0.1)(proportion),
- proportion.se = scales::label_percent(accuracy = 0.1)(proportion.se))
- ```
- ## Overrepresentation analyses across all predictions
- Use all targets
- ```{r global overrepresentation with all targets}
- d %>%
- # Remove non-responses
- filter(!actual_response %>% is.na) %>%
- # Count the number of responses on all positions
- count_possible_responses(stimulus, actual_response) %>%
- # Just order by stim and response for easier readability
- arrange(stimulus, actual_response) %>%
- # Turn count into proportion
- group_by(stimulus) %>% mutate(proportion = n / sum(n)) %>%
- # Compute the median proportion of responses on each position
- group_by(actual_response) %>% mutate(median_proportion_on_position = median(proportion)) %>%
- # Match responses to predictions -- only keep non ambiguous cases to avoid inflating our results from other patterns
- inner_join(fractions_predictions.d %>% filter(!ambiguous),
- by = c("stimulus", "actual_response" = "prediction")) %>%
- # 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
- group_by(pattern, inverted) %>%
- summarise(number_of_unambiguous_targets = n(),
- test = wilcox.test(proportion, median_proportion_on_position, paired = T, alternative = "greater") %>% (broom::tidy),
- mean_proportion = mean(proportion),
- .groups = "drop") %>%
- # Unpack test data
- unnest(cols = test) %>%
- # Correct for multiple testing
- mutate(p.value = p.value %>% p.adjust("fdr")) %>%
- # Order by pattern to ease readability2
- mutate(pattern = factor(pattern, levels = patterns.colours %>% names)) %>% arrange(pattern) %>%
- # Clean the report -- merge pattern name and inversion
- mutate(pattern = str_c(pattern, if_else(inverted, " (inverted)", "")), .keep = "unused") %>%
- # Turn statistics into APA format
- mutate(p.value = p.value %>% p_value_to_APA, mean_proportion = scales::percent_format(accuracy = 0.1)(mean_proportion)) %>%
- # Reorder and only keep relevant columns
- select(pattern, p.value, mean_proportion, number_of_unambiguous_targets, statistic)
- ```
- Exclude the four examples
- ```{r global overrepresentation with naive targets}
- d %>%
- # Remove non-responses
- filter(!actual_response %>% is.na) %>%
- # Ignore the worked out examples
- filter(!stimulus %in% c("1 / 2", "2 / 1", "3 / 6", "6 / 3")) %>%
- # Count the number of responses on all positions
- count_possible_responses(stimulus, actual_response) %>%
- # Just order by stim and response for easier readability
- arrange(stimulus, actual_response) %>%
- # Turn count into proportion
- group_by(stimulus) %>% mutate(proportion = n / sum(n)) %>%
- # Compute the median proportion of responses on each position
- group_by(actual_response) %>% mutate(median_proportion_on_position = median(proportion)) %>%
- # Match responses to predictions -- only keep non ambiguous cases to avoid inflating our results from other patterns
- inner_join(fractions_predictions.d %>% filter(!ambiguous),
- by = c("stimulus", "actual_response" = "prediction")) %>%
- # 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
- group_by(pattern, inverted) %>%
- summarise(number_of_unambiguous_targets = n(),
- test = wilcox.test(proportion, median_proportion_on_position, paired = T, alternative = "greater") %>% (broom::tidy),
- mean_proportion = mean(proportion),
- .groups = "drop") %>%
- # Unpack test data
- unnest(cols = test) %>%
- # Correct for multiple testing
- mutate(p.value = p.value %>% p.adjust("fdr")) %>%
- # Order by pattern to ease readability2
- mutate(pattern = factor(pattern, levels = patterns.colours %>% names)) %>% arrange(pattern) %>%
- # Clean the report -- merge pattern name and inversion
- mutate(pattern = str_c(pattern, if_else(inverted, " (inverted)", "")), .keep = "unused") %>%
- # Turn statistics into APA format
- mutate(p.value = p.value %>% p_value_to_APA, mean_proportion = scales::percent_format(accuracy = 0.1)(mean_proportion)) %>%
- # Reorder and only keep relevant columns
- select(pattern, p.value, mean_proportion, number_of_unambiguous_targets, statistic)
- ```
- Restrict some patterns
- ```{r global overrepresentation with restricted targets}
- d %>%
- # Remove non-responses
- filter(!actual_response %>% is.na) %>%
- # Count the number of responses on all positions
- count_possible_responses(stimulus, actual_response) %>%
- # Just order by stim and response for easier readability
- arrange(stimulus, actual_response) %>%
- # Turn count into proportion
- group_by(stimulus) %>% mutate(proportion = n / sum(n)) %>%
- # Compute the median proportion of responses on each position
- group_by(actual_response) %>% mutate(median_proportion_on_position = median(proportion)) %>%
- # Match responses to predictions -- only keep non ambiguous cases to avoid inflating our results from other patterns
- inner_join(fractions_predictions.d %>%
- filter(!ambiguous,
- !pattern %in% c("Relative", "Mixed")
- | pattern == "Relative" & prediction == 2.5
- | pattern == "Mixed" & stimulus %>% str_count(" / 2") == 1
- ),
- by = c("stimulus", "actual_response" = "prediction")) %>%
- # 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
- group_by(pattern, inverted) %>%
- summarise(number_of_unambiguous_targets = n(),
- test = wilcox.test(proportion, median_proportion_on_position, paired = T, alternative = "greater") %>% (broom::tidy),
- mean_proportion = mean(proportion),
- .groups = "drop") %>%
- # Unpack test data
- unnest(cols = test) %>%
- # Correct for multiple testing
- mutate(p.value = p.value %>% p.adjust("fdr")) %>%
- # Order by pattern to ease readability2
- mutate(pattern = factor(pattern, levels = patterns.colours %>% names)) %>% arrange(pattern) %>%
- # Clean the report -- merge pattern name and inversion
- mutate(pattern = str_c(pattern, if_else(inverted, " (inverted)", "")), .keep = "unused") %>%
- # Turn statistics into APA format
- mutate(p.value = p.value %>% p_value_to_APA, mean_proportion = scales::percent_format(accuracy = 0.1)(mean_proportion)) %>%
- # Reorder and only keep relevant columns
- select(pattern, p.value, mean_proportion, number_of_unambiguous_targets, statistic)
- ```
- ## Overrepresentation of individual predictions
- ```{r overrepresentation of individual predictions pvalues}
- overrepresentation.d = d %>%
- # Drop non-responses
- filter(!actual_response %>% is.na) %>%
- # Count answers
- count_possible_responses(stimulus, actual_response) %>%
- # Plug back some information
- # - Category, numerator, denominator for ordering
- inner_join(d %>% select(stimulus, category, numerator, denominator, response) %>% unique,
- by = c("stimulus")) %>%
- # Turn count into proportion
- group_by(stimulus) %>% mutate(total_n = sum(n), proportion = n / sum(n), ) %>%
- # Compute the median proportion of responses on each position
- group_by(actual_response) %>% mutate(median_proportion_on_position = median(proportion)) %>% ungroup %>%
- # Match the responses with patterns
- # Do not care about ambiguity since we are concerned about individual response positions
- inner_join(fractions_predictions.d %>% select(stimulus, prediction, pattern, inverted),
- by = c("stimulus", "actual_response" = "prediction")) %>%
- # Order by pattern for readability
- arrange(pattern, inverted) %>%
- # Do a one-proportion t-test, for each entry, against the median proportion
- 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)) %>%
- # Only keep some relevant values and reorder them
- select(pattern, inverted, stimulus, actual_response, proportion, statistic, parameter, p.value, median_proportion_on_position, n, total_n) %>%
- # Correct p-values
- mutate(p.value = p.value %>% p.adjust("fdr"))
- overrepresentation.d %>%
- # Format statistics
- mutate(p_value = p.value %>% p_value_to_APA,
- proportion = proportion %>% (scales::percent_format(accuracy = 1)),
- z = statistic %>% flexible_round(digits = 2),
- .keep = "unused") %>%
- # Report statistical test as per APA standards
- mutate(statistical_test = str_c("z(", parameter, ", ", total_n, ") = ", z), .keep = "unused") %>%
- # Merge pattern name and inversion
- mutate(pattern = str_c(pattern, if_else(inverted, "_inverted", "")), .keep = "unused") %>%
- # Reorder relevant columns
- select(pattern, stimulus, proportion, statistical_test, p_value)
- ```
- #### Figure 5
- Plot the distribution of responses as well as some focus on specific patterns
- ```{r global response distribution, fig.width = 8.27}
- full_response_patterns.pl = d %>%
- # Drop non-responses
- filter(!actual_response %>% is.na) %>%
- # Count the number of responses per target and position, also keep some useful data
- count(stimulus, category, numerator, denominator, is_correct, actual_response) %>%
- # Turn count into proportion per target
- group_by(stimulus) %>% mutate(proportion = n / sum(n)) %>% ungroup() %>%
- # Order the categories
- mutate(category = category %>% factor(c("n / 1", "Tenths", "Integer result", "Equivalent to 1/2"))) %>%
- # Plot the distribution of response, with targets ordered
- # - by category
- # - then by denominator,
- # - then by numerator
- ggplot(aes(x = actual_response,
- y = reorder(stimulus, 10000 * as.numeric(category) + 100 * denominator + numerator))) +
- # Fill each tile with the proportion of correct responses
- geom_tile(aes(fill = proportion)) +
- # Add an outline to correct responses
- geom_tile(aes(colour = is_correct, linewidth = is_correct), alpha = 0) +
- # Add a number line for the x-axis
- scale_x_decimal_number_line(expand = F, major.tick.size = 7) +
- # Make the y-axis neater
- scale_y_discrete(guide = "prism_offset") +
- # Color the correct responses with the color of the 'Correct' pattern for consistency
- scale_colour_manual(values = c("TRUE" = patterns.colours[["Normative"]]), breaks = c("TRUE"), labels = "Correct response") +
- # Set the size of the outline of the correct responses
- scale_linewidth_manual(values = c("TRUE" = 1.25), breaks = c()) +
- # Set the fill scale for the proportion of responses as a grey-scale gradient
- scale_fill_gradient(low = "white", high = "black", labels = scales::percent_format(), limits = c(0, .4), breaks = seq(0, .3, .1)) +
- # Set the linewith for the 'correct' response legend.
- guides(colour = guide_legend(override.aes = list(linewidth = 1.25), title = NULL)) +
- # Set nicer labels for axes
- labs(x = "Response position", y = "Target fraction", fill = "% of responses", colour = "Correct responses") +
- # Set y label in a horizontal position
- horizontal_ylab("Target\nfraction", vjust = 1.15) +
- # Set the title to be a tiny bit greater.
- theme_text(size = 14, title.size = 16, scaling_factor = PT_TO_PIXEL) +
- # Clean the plot
- theme(panel.background = element_blank(), panel.grid = element_blank(),
- panel.grid.major.y = element_blank(),
- # Set a line on the y-axis
- axis.line = element_line(colour = "black"),
- # Make the legend prettier
- legend.key = element_blank(), legend.position = "bottom",
- legend.text = element_text(vjust = 0, hjust = 0.5),
- legend.box.just = "right",
- legend.title = element_text(vjust = 0.9), legend.key.width = unit(1.6, "cm"),
- legend.spacing.y = unit(0, "mm"),
- # Adapt the title of the plot
- title = element_text(margin = margin(b = 0.5, unit = "cm"))) +
- # Set the title
- ggtitle("Full distribution of responses")
- # Load overrepresentation helper
- source("./src/overrepresentation_plot.R")
- # Make a 4-plot solution with the distribution in the top left corner and some overrepresentation lots in the others
- # GGarrange from egg package handles legends magnificiently
- egg::ggarrange(
- # Full distributions
- full_response_patterns.pl + theme(legend.box = "vertical"),
- # Overrepresentation plot for 'Relative' error pattern
- overrepresentation.d %>% overrepresentation_plot("Relative", plot_title = "Proportion of errors of type\nCorrect relative to the line"),
- # Overrepresentation plot for 'Count of units' error pattern
- overrepresentation.d %>% overrepresentation_plot("Counting_units", plot_title = "Proportion of errors of type\nCounting the numerator"),
- # Overrepresentation plot for 'Decimal' error pattern
- overrepresentation.d %>% overrepresentation_plot("Decimal", plot_title = "Proportion of errors of type\nDecimal reading"),
- # Make it 2x2
- ncol = 2, nrow = 2
- ) %>%
- # Save
- save_on_disk("Figure_5", path = "./figures/main/SVG/", device = "svg", a4_fraction = c(1, 3/4), indicate_format = F)
- ```
- ### Inversion
- Plot the frequency of answers applying the pattern on the fraction read in the conventional order or in the opposite one.
- ```{r inversion, fig.width = 8.27, fig.height = 11.69 / 4}
- # Save the data
- inversion.d = d %>%
- # Count responses -- we want all responses because we want to include non-realised predictions of the patterns
- count_possible_responses() %>%
- # Match responses with predictions from patterns...
- inner_join(
- # ...but only keep predictions that are unambiguous and have an unambiguous inverted counterpart
- fractions_predictions.d %>% filter(!ambiguous) %>% group_by(stimulus, pattern) %>% filter(n() == 2),
- by = c("stimulus", "actual_response" = "prediction")
- ) %>%
- # We no longer care about the actual value of the positions
- select(-actual_response) %>%
- # Compute proportions of responses on target for each position
- group_by(stimulus, pattern) %>% mutate(proportion = n / sum(n), proportion_half_ci = half_binomial_ci(proportion, sum(n))) %>% ungroup() %>%
- # Turn inverted into a label to make a cleaner pivot_wider
- mutate(label = if_else(inverted, "_inverted", "_direct"), .keep = "unused") %>%
- # Pivot so that every pattern-stimulus pair is matched with its _direct and _inverted proportions
- pivot_wider(names_from = label, values_from = c(proportion, n, proportion_half_ci), names_sep = "") %>%
- # The confidence interval is the same for direct and inversion matches, so remove the duplicate
- select(-proportion_half_ci_inverted) %>% dplyr::rename(proportion_half_ci = proportion_half_ci_direct) %>%
- # Retrieve useful data about the stimuli
- inner_join(d %>% select(stimulus, response, numerator, denominator) %>% unique, by = c("stimulus")) %>%
- # Reorder stimuli for comparison on graphs...
- arrange(pattern, !response %in% c(0.5, 2),
- pmax(denominator, numerator), pmin(denominator, numerator), numerator, denominator) %>%
- # And save this order into a dedicated column
- group_by(pattern) %>% mutate(order = 1:n()) %>% ungroup
- ```
- Are there differences in inversion across stimuli for some patterns?
- ```{r inversion difference between stimuli}
- inversion.d %>%
- # Chi-square proportion test over responses matching the direct pattern vs. those matching any of the two
- group_by(pattern) %>% summarise(distribution_test = prop.test(n_direct, n_direct + n_inverted) %>% (broom::tidy), .groups = "drop") %>% unnest(cols = c(distribution_test)) %>%
- # Correct and format statistics nicely
- mutate(z_test = str_c("X2(", parameter, ") = ", statistic %>% flexible_round(2)),
- # Fdr-correction for p-values
- p_value = p.value %>% p.adjust("fdr") %>% p_value_to_APA,
- alternative = alternative,
- pattern = pattern, .keep = "none")
- ```
- ```{r inversions plot, fig.width = 8.27, fig.height = 11.69 * 2/3}
- # Create a helper function for inversion plot, displaying the bias towards applying the pattern on the target fraction or its inverse.
- inversion_plot = function(data, pattern_name, never_inverted_name = "Never\ninverted", always_inverted_name = "Always\ninverted", use_quincunx_for_x_axis = F){
- data %>%
- # Place stimuli on a W shape -- only if asked for
- mutate(stimulus = if_else(order %% 2 == 0 & use_quincunx_for_x_axis, "\n", "") %>% str_c(stimulus)) %>%
- # Plot proportion of inversion per stimulus
- ggplot(aes(x = reorder(stimulus, order), y = proportion_direct - 0.5, fill = pattern,
- ymin = proportion_direct - 0.5 - proportion_half_ci, ymax = proportion_direct - 0.5 + proportion_half_ci)) +
- # Proportions are bars
- geom_bar(stat = "identity") +
- # Add half binominal confidence interval as error bars
- geom_errorbar(width = 0.1) +
- # Mark the 50-50 line
- geom_hline(yintercept = 0, linewidth = 1.5, linetype = 2) +
- # Do not stick the bars on the axis as this makes ugly overlaps
- coord_cartesian(expand = TRUE) +
- # Use color to indicate the pattern
- scale_fill_manual(values = patterns.colours) +
- # Set the scale to indicate relative preferences
- scale_y_continuous(
- limits = c(-0.5,0.5), breaks = c(-0.5, 0, 0.5),
- labels = c(always_inverted_name, "50-50", never_inverted_name)) +
- # Set labels
- labs(x = "Target", y = "% of responses without inversion", fill = "Pattern") +
- # Clean the theme
- theme(
- # Remove background elements
- panel.background = element_blank(), panel.grid = element_blank(),
- # Remove everything but the stimuli from the x-axis
- axis.line.x = element_blank(), axis.ticks.x = element_blank(), axis.title.x = element_blank(),
- # Remove the ticks and title but restore the line of the y-axis
- axis.line.y = element_line(colour = "black"), axis.ticks.y = element_blank(), axis.title.y = element_blank(),
- # No need for a legend, fill colors are only evocative
- legend.position = "none",
- # Add some margins so that everything can be read
- plot.margin = unit(c(1, 1, 0.1, 0.1), "cm")
- ) +
- ggtitle(label = pattern_name)
- }
- # Make a triple plot with inversion plots on each line
- egg::ggarrange(
- # One for correct vs.inverted errors
- inversion.d %>% filter(pattern == "Normative") %>% inversion_plot("Correct or inverted responses", "Correct", "Inverted") + horizontal_ylab("Bias towards", vjust = 1.2) ,
- # One for decimal confusion errors
- inversion.d %>% filter(pattern == "Decimal") %>% inversion_plot("Decimal reading errors", "Numerator first", "Denominator first"),
- # One for count of tenths errors
- inversion.d %>% filter(pattern == "Counting_tenths") %>% inversion_plot("Count of tenths errors", "Count numerator", "Count denominator", use_quincunx_for_x_axis = T)
- ) %>%
- # Save
- save_on_disk("Figure_6", path = "./figures/main/SVG/",
- device = "svg", a4_fraction = c(1, 2/3), indicate_format = F)
- ```
- # Session ending details
- ```{r saveFigs}
- global_pptx %>% print(target = "./analysis_all_figs.pptx")
- sessionInfo()
- ```
paper_analysis.Rmd, no license · at the source
Overview
- Université Paris Saclay, INSERM, CEA, Cognitive Neuroimaging Unit, NeuroSpin Center, 91191, Gif-sur-Yvette, France
- Collège de France, PSL University, Paris, France
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
Availability: 1 check, the latest on 30 September 2026: the link answers (HTTP 200)
- 30 September 2026: the link answers (HTTP 200)
3 files
- paper_analysis.Rmd, R, 1,122 lines
- paper_analysis_exp_2.Rmd
, R, 800 lines - supplementary_analyses.R
md , R, 605 lines
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://
BibTeX
@article{valerio2026cort
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/
url = {https://
}
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/
SN - 2692-8205
PB - bioRxiv
DO - 10.64898/
UR - https://
ER -
CSL-JSON
{
"id": "10.64898/
"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":
"DOI": "10.64898/
"ISSN": "2692-8205",
"publisher": "bioRxiv",
"URL": "https://
"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 biologyIn 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: eLifeIn 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 biologyIn 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 AmericaIn 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 AmericaIn 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: eNeuroIn 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 communicationsIn common: broom, tidyverse, 9 references
- [8] doi:10.1002/hbm.70434 [code]
- Network Neuroscience of Human Multitasking: Local Connections Matter.Journal: Human brain mappingIn 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 neuroscienceIn 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.
Claim this paper
Correct its record
Say what each link of this record is, remove the ones that are not the paper's, add the ones that are missing. The correction becomes a new version of the record, in its Versions section.
Validate its tracing map
You validate the map as this page shows it: 1 repository of the authors' code, each at its verified commit and with its license, 3 scripts, and 0 matches between paragraphs and code (see the Code and Map sections). It then receives a DOI on Zenodo, with you (your ORCID iD) and OSCR as its creators; the code itself is not deposited.
The map's fingerprint: sha256:37eaff8c93e29841…
Add the badge to the code's README
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 -->
[](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:endHTML
<!-- 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.
