Skip to content

rcicr 1.3.0

Latest

Choose a tag to compare

@rdotsch rdotsch released this 18 Aug 05:41
· 9 commits to main since this release
4b83e08

Behaviour changes

  • A decorated z-map on a device too small for it now says so, and can be made to fit.
    plotZmap(decoration = TRUE) needs room for margins, labels and the colour scale — about
    160px at the default text size on Linux and macOS, and about 200px on Windows, whose
    graphics device is 96 ppi where theirs is 72 — and below that base R stopped with figure margins too large, naming neither rcicr nor a way forward. It now stops with a message giving the
    size, the minimum, and the three ways out. plotZmap() gains pointsize and
    generateCI() gains zmappointsize: the decoration is measured in lines of text, so a
    smaller size fits it onto a smaller image. This matters most through generateCI(), which
    sizes the z-map to img_size, so a stimulus set below about 160px could not produce a
    decorated z-map at all — a 128px set now can, with zmappointsize = 6.

    Both arguments default to the graphics device's own 12, so every existing call renders
    exactly as before; a 512px decorated z-map is byte-identical.

  • plotZmap() no longer depends on raster, and its ... arguments now go to
    graphics::image() instead of the raster package's plot method.
    col behaves the same
    way in both (and now actually works — see the bug fixes below); a call passing an argument
    specific to that method will now be rejected as unused. Dropping the dependency also removes
    terra, sp and Rcpp, and with them the GDAL/GEOS/PROJ system libraries that every
    Linux CI job had to install before it could start.

    The z-map itself renders identically — the undecorated figures generateCI() writes
    are pixel-for-pixel the same as before, within colour quantisation, and a golden reference
    rendered by the old raster code is committed as a test fixture to keep it that way. The
    palettes are unchanged, including the quirk that a z-map drawn over a background image uses
    the default palette rather than the viridis one.

  • A decoration = TRUE z-map is laid out slightly differently. The colour bar is now
    drawn by hand rather than by raster, and the map is a few pixels wider at 512px. If you
    regenerate a decorated z-map figure, it will not be pixel-identical to one saved with 1.2.3
    — the same is already true of regenerating it on a different operating system, and for the
    same reason: what a graphics device paints is not part of what this package computes. No
    z-score, classification image, scaling result or informational value changes, and
    generateCI()'s own z-map figures are undecorated and unaffected. See
    ?plotZmap, "Reproducibility across platforms".

  • generateCI(mask = matrix(NA, 1, 1)) now reports the malformed mask instead of silently
    ignoring it.
    The internal test for "was a mask supplied?" asked only whether the argument
    was a single NA — which a one-cell NA matrix, a one-element array(NA) and a list(NA)
    all are. Such a mask was mistaken for the NA default, discarded without a word, and an
    entirely unmasked classification image came back. A call that passes a mask and gets an
    unmasked CI is the failure worth catching early; it now stops in the same mask validation
    every other malformed mask reaches. The sentinel is now specifically an atomic scalar with no
    dimensions, so mask = NA still means "no mask" — it is the default, and every unmasked call
    relies on it — while matrix(1, 1, 1), larger matrices, PNG paths and NULL are unchanged.

  • generateStimuli2IFC() now checks base_face_files before it generates anything,
    and names the entry it cannot use.
    Four inputs used to get past the old check and
    fail from inside a parallel worker with attempt to select less than one element in get1index, which names neither the argument nor the file: a list with no names, a
    list with some names missing, an empty list, and an element that is not a single file
    name. They now stop immediately with a message saying which entry is wrong and why.

    One of them could previously appear to work. A base_face_files with two entries
    under the same name silently dropped all but the first
    — the loop looked each name
    up by string, so list(face = 'a.png', face = 'b.png') produced one set of stimuli,
    from a.png, and nothing at all from b.png. That is now an error naming the
    duplicated name. If you have a script that relies on it, the stimuli it produced were
    never what the call asked for; give each base image its own name.

  • The PNG-or-JPEG test now looks at the file extension rather than anywhere in the
    path.
    It was grepl('png|PNG', filename) against the whole path, so a JPEG stored
    under a directory called png was handed to png::readPNG() and died with file is not in PNG format, blaming the file for a choice the package had made. Files are now
    recognised by a .png, .jpg or .jpeg extension, case-insensitively. A base image
    whose extension does not say what it is is rejected up front, by name, instead of
    reaching a reader that cannot parse it.

  • The .Rdata validation errors now name the version of rcicr that wrote the file.
    generateCI() and computeCumulativeCICorrelation() already said which field a file was
    missing; they now also say where the file came from, which is what turns "this file has no
    stimuli_params" into "it predates the version that added it — regenerate the stimuli, or
    install that version". The version is read tolerantly, because the field cannot be taken at
    face value: p$generator_version is preferred over the top-level generator_version, which
    every release from 0.4.0 through 1.1.0 recorded as a hardcoded 0.4.0, and a file that only
    claims 0.4.0 is reported as unknown rather than as 0.4.0. A file with no version field at
    all reports the absence and stops there — an absent field is equally a file older than 0.4.0,
    a truncated one, or one rcicr never wrote.

    generateReferenceDistribution2IFC()'s two warnings about a missing nscales or
    noise_type were reworded for the same reason. They said the file "was written by a version
    of rcicr that did not save" the field, which an absence does not establish; they now report
    what is missing, and keep the version as context rather than as a conclusion. The advice is
    unchanged, and was always right either way: regenerate the stimulus set with this version.

  • A stimulus file with gabor noise and no saved sigma now says so.
    generateReferenceDistribution2IFC() assumes the historical default of 25 when a file
    predates 1.1.0 and lacks the field, which it has always done silently — unlike the loud
    warnings for a missing nscales or noise_type. For gabor noise that silence hid the same
    hazard those warnings exist for: sigma is what shapes the Gaussian mask, so guessing it
    wrong rebuilds the null on a different noise basis than participants saw, and the resulting
    InfoVal is wrong. On a 1.0.1 gabor stimulus set the reference norms move from
    0.681/0.689/0.680 at sigma = 25 to 0.615/0.620/0.626 at sigma = 10.

    Sinusoidal files are unaffected and stay silent, which is the point of doing this by
    noise type rather than by field: sigma reaches the basis through generateGabor() alone,
    so for sinusoidal noise the norms are identical whatever it is, and a warning would be pure
    noise on the far more common legacy file. Nothing warns that did not previously produce a
    wrong answer, and no numeric output changes.

  • plotZmap(mask = ...) now accepts a mask with an alpha channel. It previously required
    every colour channel of a multi-channel PNG mask to match exactly, so a greyscale-plus-alpha
    or RGBA mask whose alpha plane happened to differ from its colour planes was rejected — alpha
    carries no colour information and is now always ignored, matching generateCI(mask = ...).
    A rectangular zmap/mask pair continues to work as before. plotZmap(mask = ...) also now
    rejects a mask that is neither a string nor a matrix with a clear error, instead of failing
    later inside png::readPNG() with an unrelated message.

  • plotZmap(mask = NA) now means "no mask", as it already does in generateCI(). The two
    functions detected a supplied mask differently — plotZmap() asked only whether the argument
    was non-NULL — so the same sentinel meant opposite things: generateCI() read NA as "no
    mask" while plotZmap() passed it on and stopped with The mask argument is neither a string nor a matrix!. NaN behaved the same way. Both now render an unmasked z-map. No call that
    worked before changes: the inputs affected all raised that error.

  • generateStimuli2IFC() no longer saves trial in the .Rdata file. It was the loop
    counter left over from stimulus generation — always equal to n_trials, which is already
    saved. Nothing in the package or the documented contract reads it. Existing .Rdata files
    that contain trial continue to work; the field is simply ignored on load.

Reproducibility impact

  • Individual-CI PNGs written by generateCI(save_individual_cis = TRUE) carried the wrong
    participant's name, and are now named correctly.
    The per-participant loop selects each
    participant's trials by sorted order and took the output filename from order of
    appearance. Where those two orders differ, every file in <targetpath>/individual_cis
    was given another participant's ID. The pixels were always right; only the names were
    wrong.

    Affected: a direct call to
    generateCI(participants = ..., save_individual_cis = TRUE) where the participants
    vector is not already in sorted order. Such a call produced correct images under incorrect
    filenames, so a figure published as participant p2 may be someone else's classification
    image.

    Do not assume tidy data was safe — the common case is affected. Sorting is lexical,
    so text labels like "p10" sort before "p2". A study whose participants appear in
    collection order p1, p2, ..., p10 is therefore affected from the tenth participant
    onward, even though nothing about that data is out of order in any ordinary sense:

    participants, in collection order affected
    "p1""p9" no
    "p1""p10" or beyond yes
    "p01""p12" (zero-padded) no
    "1""12" (character) yes
    112 (numeric) no

    Unpadded text IDs and ten or more participants is the ordinary shape of a reverse
    correlation study, so if that describes yours and you used the call above, check rather
    than assume.

    Not affected — and this is the documented route, so most analyses are in this group:

    • batchGenerateCI(), batchGenerateCI2IFC() and generateCI2IFC(). None of them
      exposes participants or save_individual_cis at all; they split the data themselves
      and name each image from the grouping value directly, never reaching the code that was
      wrong. Per-participant classification images computed the way the package documentation
      has recommended since the CRAN era — batchGenerateCI2IFC(), and batchGenerateCI() in
      the current vignette — were correctly labelled throughout.
    • Everything generateCI() returns. The group classification image is a mean across
      participants and so does not depend on their order; the per-participant stack, and every
      z-map built from it, were internally consistent throughout. No returned value, and no
      number in any .Rdata file, ever changed.
    • Any call that left save_individual_cis at its default FALSE. No individual-CI
      file was written, so there was nothing to mislabel.
    • Participants already in sorted order, where the two orderings coincide.
    • Every CRAN release. rcicr was on CRAN from July 2014 until it was archived in June
      2021; the last release, 0.3.4.1 (July 2016), predates the save_individual_cis option
      entirely. install.packages('rcicr') never produced a mislabelled file at any point.

    If you have published from individual-CI images, check your analysis scripts. Two
    steps, neither of which requires re-running anything.

    Step 1 — did you use the affected call at all? Look for an individual_cis/ directory
    in your output. Nothing else in the package creates it, so if it is not there the affected
    call never ran. If your per-participant images came from batchGenerateCI() or
    batchGenerateCI2IFC(), the same applies: those cannot reach the defect.

    Check the output rather than the script. save_individual_cis is the sixth formal of
    generateCI(), so a call can set it positionally —
    generateCI(stim, resp, "face", rdata, pids, TRUE, ...) — and write those files without
    the argument name appearing anywhere; do.call() hides it the same way. Searching your
    scripts and finding nothing does not clear an analysis. Finding no individual_cis/ does.

    Step 2 — if the directory is there, was your participants vector in sorted order?
    Note "sorted" means lexically sorted for text labels, per the table above — sorting your
    data frame by a "p1"-style column does not make it safe once you reach ten participants.
    Where you still have the vector, one expression settles it:

    identical(unique(participants), sort(unique(participants)))   # TRUE = names were correct

    If the output is long gone and only the script survives, search it for individual rather
    than the full argument name, and read by hand any generateCI() call passing six or more
    arguments positionally.

    Recovering existing output is a rename, not a re-run. The mapping is exact: the file
    named unique(participants)[i] holds the classification image of
    sort(unique(participants))[i].

    old <- unique(participants)         # the names the files were given
    new <- sort(unique(participants))   # the participants they actually hold

    Every release from 1.0.1 (January 2023) through 1.2.3 (August 2026) carries the
    defect
    , as does every install from the development branch from 15 August 2017 onward —
    the date it was introduced, in the commit that first made individual-CI saving work at
    all. There were no releases between 2017 and 2023, so a copy obtained in those years came
    from the branch and is affected too.

    Getting it from GitHub took a deliberate step, until December 2021. The default branch
    was master, which carried an older repository layout with no DESCRIPTION at its root, so
    install_github('rdotsch/rcicr') did not install anything at all — it failed. The affected
    code was on the development branch, reached only by asking for it:
    install_github('rdotsch/rcicr', ref = 'development'), which the README of the time
    labelled "AT YOUR OWN RISK" and specifically advised against for analyses meant for
    publication. development became the default branch on 2021-12-28, and from then a plain
    install returns affected code.

    If you need to work out which version a stored analysis actually used, the version number
    alone will not tell you: 0.4.0 sat on the development branch for five years, with the
    defect entering partway through. A note listing every release, its date, whether it came
    from CRAN or GitHub, and what "the latest version" gave you in each window is at
    https://github.com/rdotsch/rcicr/blob/main/notes/individual-ci-mislabelling.md.

  • computeCumulativeCICorrelation() with a masked targetci now returns numeric
    correlations where it previously returned all-NA. No existing analysis could have used
    the old result — it carried no information — but code that checked for NA on the returned
    curve will see a different answer. Unmasked targets are bit-identical.

Bug fixes

  • generateCI(save_individual_cis = TRUE) names each PNG for the participant it was
    actually computed from.
    The loop selects participants by sorted level order and the
    filename was taken from order of appearance, so the two disagreed for any data not already
    sorted by participant. See "Reproducibility impact" above for how to tell whether files you
    already have are affected, and how to correct them without recomputing anything.

  • computeCumulativeCICorrelation() now returns real correlations when targetci was
    generated with a mask.
    generateCI() stores NA in every pixel a mask excludes,
    and the correlation was taken over all pixels (use = "everything"), so a single masked
    pixel made every point on the curve NA however strong the signal. Correlations are now
    computed over the unmasked pixels only. A fully masked target (every pixel NA) still
    returns an all-NA curve — there are no complete pairs to correlate.

  • plotZmap(col = ...) works. Supplying a palette is how ?plotZmap has always told
    you to change the colours, and doing it stopped the call with formal argument "col" matched by multiple actual arguments before anything was drawn — the function passed its
    own col alongside yours. This affected every released version; the argument is now taken
    as an override, and is used for the colour bar as well as the map.

  • A base image with no contrast no longer becomes an all-NaN base image. Under
    maximize_baseimage_contrast = TRUE, the default, generateStimuli2IFC() rescales
    with (img - min(img)) / (max(img) - min(img)) — which is 0/0 when every pixel is the
    same value. The resulting NaN base face was written into the .Rdata with no error
    and no warning, every classification image computed from that stimulus set inherited
    it, and the stimuli themselves came out uniformly black, png::writePNG() clamping
    NaN to zero. It now stops with an error naming the file.

    A photograph is never uniform, so this bit synthetic and accidentally-blank base
    images — but it failed silently, and the symptom appeared a long way from the cause.
    The error fires only under maximize_baseimage_contrast = TRUE: a flat base image is
    perfectly usable with the rescale switched off, and the message says so.

  • computeCumulativeCICorrelation() reads pre-0.3.0 stimulus files again. Files written
    before rcicr 0.3.0 (January 2015) store 4096 contrast parameters per trial where only 4092
    patches exist, and generateCI() has truncated the four unused columns for years.
    computeCumulativeCICorrelation() did not, so on such a file the extra columns reached
    generateNoiseImage() as a length mismatch and it aborted with "number of parameters
    doesn't equal number of patches" — the cumulative-correlation curve could not be computed at
    all. It now applies the same truncation. Files from 0.3.0 onward already have 4092 parameters
    and are unaffected. The same fix keeps a single presented stimulus two-dimensional, so a
    one-stimulus call — which aborted with "incorrect number of dimensions" on any file — now
    returns its (single-point) curve.

  • generateCI() with participants works again on more than one core. Supplying
    participants without targetpath — the normal thing to do when you are not saving
    individual CIs — stopped with argument "targetpath" is missing, with no default as soon as
    n_cores was greater than 1, which is the default on any multi-core machine. foreach
    inspects the loop body and fetches every variable it mentions, including one used only in
    the branch that saves individual CIs, and that branch is precisely the one that cannot run
    when you have not asked for those files. Single-core calls were unaffected, as was saving
    individual CIs with a targetpath.

  • The progress bar moves again during parallel runs. generateStimuli2IFC() and
    generateCI() build their progress bar in your session but were advancing it from inside
    the parallel loop, where each worker holds a private copy — so under the default
    (ncores/n_cores greater than 1) the bar sat at 0% for the whole job and jumped to 100%
    at the end. On a 770-trial 512px set that is a long time with nothing to watch. Affects
    stimulus generation, per-participant CIs, and t.test z-maps. Serial runs were never
    affected and are unchanged.

    This required swapping the foreach backend from doParallel to doSNOW, which is the
    one that can report task completions back to the parent session. doParallel is no longer
    a dependency; doSNOW and snow take its place, for slightly less installed than it took.
    Nothing about how the loops compute changes, and no numeric output changes.

  • The base_face_files type check raises an error you can actually read. It wrote
    its explanation to stderr() and then called stop() with no arguments, so the
    condition it raised carried an empty message: conditionMessage() returned "", and
    anything wrapping the call — a Shiny app, a batch script, knitr — caught an error it
    could not report. Under sink() or capture.output(type = "message") the explanation
    could be separated from the failure entirely. The text now travels with the condition.

  • A base image that cannot be read is reported with the file it came from. The
    reader's own complaint (file is not in PNG format, and the like) is kept and
    prefixed with the entry's name and path, and a file that does not exist is now
    reported as missing rather than as unopenable.

  • Base images are validated before the noise basis is built and before the output
    directory is created.
    A mistyped path used to cost the full noise-pattern
    computation — appreciable at the default 512px — and left an empty stimulus directory
    behind before reporting the problem.

    No numeric output changes. Every check here either replaces an error with a clearer
    error, or rejects input that could not have produced correct stimuli; a call that
    succeeds today produces exactly what it did before.

  • A multi-channel PNG mask one pixel wide or tall is now rejected instead of silently
    masking everything.
    Dropping the colour channel from such a mask also dropped its
    singleton spatial dimension, leaving a plain vector whose absent dimensions made the
    size check pass vacuously; the mask was then recycled by linear indexing. A 1-by-8 RGB
    mask against an 8-by-8 stimulus set returned an entirely NA classification image with
    no error or warning. It now fails the size check and says so. Masks of any other shape
    are unaffected.

  • generateCI(mask = ...) no longer crashes on a greyscale-plus-alpha PNG mask. The
    internal mask importer hardcoded three colour channels, so any 2-channel PNG (the form
    png::readPNG() produces for 8-bit greyscale-plus-alpha) failed with subscript out of bounds rather than a clean error — plotZmap(mask = ...)'s separate, now-removed mask code
    already accepted the same file. Mask import is now shared between the two functions (#185),
    so both accept a 2-channel mask, and a 4-channel (RGBA) mask continues to work exactly as
    before. The mask-size-mismatch message now names what the mask is being checked against
    ("stimuli" from generateCI(), "z-map" from plotZmap()) instead of always saying
    "stimuli", and reports the mask's dimensions in the same row-by-column order as the
    target's rather than transposed.

Documentation

  • ?computeCumulativeCICorrelation explains what its curve ending at 1 does and does not
    mean.
    With no targetci, the final CI it compares against is built from the same
    un-aggregated trials as the curve, so wherever the evaluated trials reach the last one the curve
    ends at 1 — self-consistency, not convergence. That is every call at the default step = 1,
    though a larger step can stop short of the final trial and end below 1, and responses that
    cancel exactly give a constant CI and an all-NA curve. That final CI is
    identical to generateCI()'s where every stimulus was
    presented the same number of times, and differs where repeat counts vary, because this
    function weights each trial equally while generateCI() weights each unique stimulus equally.
    Measured on an 8-trial set with counts 4/2/1/1, the two correlate at 0.77. Pass
    targetci = generateCI(...) when you want the curve to describe the CI you will report.
    Nothing changed in what the function computes.

  • ?generateCI documents the weighting of repeated stimulus presentations. When
    participants is NA, repeated presentations are collapsed before building the CI: each
    unique stimulus gets equal weight, regardless of how many times it was presented. With equal
    repeat counts this changes nothing; with unequal counts it is a different estimand from
    weighting each trial equally, and the difference can be substantial. Previously this was
    described only as a performance optimisation in a code comment.

  • There is now a documentation website: https://rdotsch.github.io/rcicr/. The
    function reference, both vignettes and this changelog are readable without installing
    the package first. It is generated from the same sources — README.md is the home page,
    so "How it works" and "Anatomy of the .Rdata file" are not maintained twice — and
    rebuilt by GitHub Actions on every push, so it cannot drift from the code.

  • The repository now carries a CITATION.cff, so GitHub offers a "Cite this repository"
    button.
    citation("rcicr") is unchanged and still reads inst/CITATION, which remains the
    citation of record; the new file is generated from it and DESCRIPTION by cffr, and
    carries it as preferred-citation. The two cannot drift apart: CI regenerates the file,
    compares every field, and validates the result against the CFF schema.

  • The install instructions now install a release rather than the development version.
    remotes::install_github('rdotsch/rcicr') takes the tip of main, which carries unreleased
    changes; the README.md and walkthrough-vignette instructions now lead with
    @*release, and show @v1.2.3 for installing one specific version — worth recording in an
    analysis script, since a classification image is only reproducible against the version that
    computed it.

  • ?generateGabor now documents generateGabor(). Its example called
    generateSinusoid(), so the help page and the reference site demonstrated a call that never
    reached the function being documented; @return described a sinusoid rather than a gabor
    patch, and @param sigma was an unfinished sentence that did not say what the value means.
    sigma is the standard deviation of the Gaussian mask in pixels — measured, not
    inferred: the mask falls to exp(-1/2) at 24.5 px from centre for sigma = 25. The
    function itself is unchanged.

Internal

  • The pre-0.3.0 0-indexed noise path is now pinned against an independent oracle. Reading a
    .Rdata file whose patch indices start at 0 takes a backward-compatibility branch in
    generateNoiseImage() that drops the 0-indexed cells and recycles a too-short weight vector —
    which looks like it could misalign the whole image. It does not: the last patch layer is
    all-zero, so the recycled values land only where the patch is zero and are multiplied away.
    A test confirms the output equals the honest "one patch not shown" result exactly, so no
    change to the indexing can silently reintroduce a misalignment. No behaviour changes.

  • Stimulus sets generated by 1.0.1 and 1.1.0 are now covered by tests. The suite carries
    .Rdata files those releases actually wrote — generated by installing each tag, not
    reconstructed — and checks that the current generateCI() still reads them and produces a
    classification image. A 1.0.1 file predates nscales, so
    generateReferenceDistribution2IFC() has to assume it: the test asserts the null it builds is
    bit-identical to one from a file that states nscales outright, because a null built on the
    wrong noise basis finishes just as happily and is wrong. (The parallel sigma fallback only
    affects Gabor noise, which these sinusoidal fixtures do not exercise — tracked separately.)
    Every other
    fixture is built by the current generator, so nothing before this could catch a field this
    version expects and an older one never saved. It runs on every platform with no network: the
    fixtures are 205 KB (1.0.1) and 45 KB (1.1.0).