cosmo stat skl

function stat_ds = cosmo_stat(ds, stat_name, output_stat_name)
    % compute t-test or F-test (ANOVA) statistic
    %
    % stat_ds=cosmo_stats(ds, stat_name[, output_stat_name])
    %
    % Inputs:
    %   ds                dataset struct with
    %                       .samples PxQ, for P observations on Q features
    %                       .sa.targets Px1 observation conditions (classes)
    %                       .sa.chunks  Px1 observation chunks (e.g. subjects).
    %   stat_name         One of [*]:
    %                     't' : one-sample t-test against zero (nclasses==1),
    %                           or paired t-test (nclasses==2)
    %                     't2': two-sample t-test with equal variance,
    %                           (nclasses==2)
    %                     'F' : one-way ANOVA or repeated measures ANOVA
    %                           (nclasses>=2)
    %                     [*]  nclasses is the number of unique values in
    %                          ds.sa.targets
    %                     [**] if nclasses==2, the contrast of unq(1) minus
    %                          unq(2) is returned, where
    %                            unq=unique(ds.sa.targets);
    %                          Because unique returns the results in sorted
    %                          order from small to large, this means that
    %                          the contrast is computed of the smaller value
    %                          of targets minus the larger. This may be somewhat
    %                          confusing, in the sense that the output statistics
    %                          (t- or z-score) gives a positive value if samples
    %                          associated with the lower .sa.targets value have a
    %                          higher mean than those associated with the
    %                          higher .sa.targets value.
    %   output_stat_name  (optional) 'left', 'right', 'both', 'z', 'p', or
    %                      the empty string '' (default).
    %                     - 'left', 'right', and 'both' return a p-value with
    %                        the specified tail.
    %                     - 'p' returns a p-value, with tail='right' if
    %                        stat_name='F' and tail='both' otherwise.
    %                     - 'z' returns a z-score corresponding to the p-value
    %                     - '' (empty) returns the t or F statistic.
    %                     Missing values can be indicated by NaNs; if these are
    %                     present and a p-value or z-score is returned, then
    %                     these values are computed with a possible variable
    %                     number of degrees of freedom across features.
    %
    % Returns:
    %   stat_ds          dataset struct with fields:
    %     .samples       1xQ statistic value, or (if output_stat_name is
    %                    non-empty) z-score or p-value. See the Notes below
    %                    for interpreting p-values.
    %     .sa            struct with field:
    %        .stats      One of 'Ftest(df1,df2)', 'Ttest(df)', 'Zscore()', or
    %                    'Pval()', where df* are the degrees of freedom
    %     .[f]a          identical to the input ds.[f]a, if present.
    %
    % Examples:
    %     % one-sample t-test
    %     % make a simple dataset
    %     ds=struct();
    %     ds.samples=reshape(mod(1:7:(12*3*7),13)',[],3)-3;
    %     ds.sa.targets=ones(12,1);
    %     ds.sa.chunks=(1:12)';
    %     cosmo_disp(ds.samples);
    %     %||   [ -2         4        -3
    %     %||      5        -2         4
    %     %||     -1         5        -2
    %     %||      :         :         :
    %     %||      9         2         8
    %     %||      3         9         2
    %     %||     -3         3         9 ]@12x3
    %     %
    %     % run one-sample t-test
    %     s=cosmo_stat(ds,'t');
    %     cosmo_disp(s);
    %     %|| .samples
    %     %||   [ 2.49      3.36      2.55 ]
    %     %|| .sa
    %     %||   .stats
    %     %||     { 'Ttest(11)' }
    %     %
    %     % compute z-score of t-test
    %     s=cosmo_stat(ds,'t','z');
    %     cosmo_disp(s);
    %     %|| .samples
    %     %||   [ 2.17      2.73      2.21 ]
    %     %|| .sa
    %     %||   .stats
    %     %||     { 'Zscore()' }
    %     %
    %     % compute (two-tailed) p-value of t-test
    %     s=cosmo_stat(ds,'t','p');
    %     cosmo_disp(s);
    %     %|| .samples
    %     %||   [ 0.03   0.00633    0.0268 ]
    %     %|| .sa
    %     %||   .stats
    %     %||     { 'Pval()' }
    %     %
    %     % compute left-tailed p-value of t-test
    %     s=cosmo_stat(ds,'t','left');
    %     cosmo_disp(s);
    %     %|| .samples
    %     %||   [ 0.985     0.997     0.987 ]
    %     %|| .sa
    %     %||   .stats
    %     %||     { 'Pval()' }
    %
    %     % one-way ANOVA
    %     % each observation is independent and thus each chunk is unique;
    %     % there are three conditions with four observations per condition
    %     ds=struct();
    %     ds.samples=reshape(mod(1:7:(12*3*7),13)',[],3)-3;
    %     ds.sa.targets=repmat(1:3,1,4)';
    %     ds.sa.chunks=(1:12)';
    %     s=cosmo_stat(ds,'F');
    %     cosmo_disp(s);
    %     %|| .samples
    %     %||   [ 0.472    0.0638      0.05 ]
    %     %|| .sa
    %     %||   .stats
    %     %||     { 'Ftest(2,9)' }
    %     % compute z-score
    %     s=cosmo_stat(ds,'F','z'); % convert to z-score
    %     cosmo_disp(s);
    %     %|| .samples
    %     %||   [ -0.354     -1.54     -1.66 ]
    %     %|| .sa
    %     %||   .stats
    %     %||     { 'Zscore()' }
    %
    %
    %     % two-sample t-test
    %     % each observation is independent and thus each chunk is unique;
    %     % there are two conditions with four observations per condition
    %     ds=struct();
    %     ds.samples=reshape(mod(1:7:(12*3*7),13)',[],3)-3;
    %     ds.sa.targets=repmat(1:2,1,6)';
    %     ds.sa.chunks=(1:12)';
    %     s=cosmo_stat(ds,'t2','p'); % return p-value
    %     cosmo_disp(s);
    %     %|| .samples
    %     %||   [ 0.0307  0.000242  7.07e-05 ]
    %     %|| .sa
    %     %||   .stats
    %     %||     { 'Pval()' }
    %     %
    %     % for illustration, this test gives the same p-values as a
    %     % repeated measures ANOVA
    %     s=cosmo_stat(ds,'F','p');
    %     cosmo_disp(s);
    %     %|| .samples
    %     %||   [ 0.0307  0.000242  7.07e-05 ]
    %     %|| .sa
    %     %||   .stats
    %     %||     { 'Pval()' }
    %
    % Notes:
    %  - If output_stat_name is not provided or empty, then this function runs
    %    considerably faster than the builtin matlab functions
    %    (ttest, ttest2, or anova1).
    %  - In the case of two classes, the sign of the result may seem counter-
    %    intuitive (see the [**]) comment above).
    %  - When output_stat_name=='p' then the p-values returned are the same as
    %    the builtin matlab functions anova1, ttest, and ttest2 with the
    %    default tails.
    %  - To run a one-sample t-tests against x (if x~=0), one has to
    %    subtract x from ds.samples before using ds as input to this function
    %  - The .sa.chunks and .sa.targets determine which test is performed:
    %    * statname=='t': all chunks are unique => one-sample t-test
    %                   : each chunk present twice => paired-sample t-test
    %    * statname=='F': all chunks are unique => one-way ANOVA
    %                   : each chunk present N times => repeated measures ANOVA
    %    See cosmo_montecarlo_cluster_stat for examples on how .sa.targets and
    %    .sa.chunks should be set for different statistics.
    %  - Missing values can be indicated by NaNs, and if the output is a
    %    p-value (or a z-score based on the p-value), then the output is
    %    computed for different features using varying degrees of freedom.
    %    For example, if the dataset has 10 samples and a one-sample t-test is
    %    used, z-scores for samples with no NaN values is based on the
    %    t-statistic with df=9, but those with two missing values (NaN values)
    %    are based on the t-statistic with df=7. A use case is computing fMRI
    %    group statistics where overlap across brains is not perfect at the
    %    voxel-by-voxel level, in as similar approach as AFNI's 3dttest++ with
    %    the '-toz' option.
    %  - This function computes feature-wise statistics that are not corrected
    %    for multiple comparisons. To correct for multiple comparisons, see
    %    cosmo_montecarlo_cluster_stat.
    %
    % See also: anova1, ttest, ttest2, cosmo_montecarlo_cluster_stat
    %
    % #   For CoSMoMVPA's copyright information and license terms,   #
    % #   see the COPYING file distributed with CoSMoMVPA.           #

    if nargin < 3
        output_stat_name = '';
    end

    [output_stat_name, tail] = get_stat_definition(stat_name, ...
                                                   output_stat_name);
    [samples, targets, chunks, type] = get_descriptors(ds);

    % run specified helper function
    if isfield(ds.sa, 'contrast')
        contrast = ds.sa.contrast;
    else
        contrast = [];
    end

    % ensure stat_name matches the number of classes
    verify_class_count(stat_name, targets);

    % get ttest1, ttest2 or Ftest function handle
    stat_func = get_stat_func(stat_name);

    % apply statistic function handle
    [stat, df_struct, stat_label] = apply_stat_func(stat_func, ...
                                                    samples, targets, chunks, type, contrast);

    % convert to p-value or z-score, if necessary
    [stat, stat_label] = compute_output_stat(stat, df_struct, stat_label, ...
                                             tail, output_stat_name);

    % store output
    stat_ds = struct();
    if isfield(ds, 'a')
        stat_ds.a = ds.a;
    end
    if isfield(ds, 'fa')
        stat_ds.fa = ds.fa;
    end
    stat_ds.samples = stat;
    stat_ds.sa.stats = {stat_label};

    %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
    % helper functions
    %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
function [stat, df_struct, stat_label] = apply_stat_func(stat_func, ...
                                                         samples, targets, chunks, ...
                                                         type, contrast)
    % applies the stat_func to samples, taking into account missing values
    % (indicated by NaN) in samples
    samples_nan_msk = isnan(samples);
    has_missing = any(any(samples_nan_msk, 1) & ~all(samples_nan_msk, 1));
    if ~has_missing
        [stat, df_struct, stat_label] = apply_stat_func_no_nans(stat_func, ...
                                                                samples, targets, chunks, ...
                                                                type, contrast);
        return
    end

    [nsamples, nfeatures] = size(samples);
    stat = zeros(1, nfeatures);

    zeroed_samples = samples;
    zeroed_samples(~samples_nan_msk) = 0;

    nan_counts = sum(samples_nan_msk, 1);
    unq_counts = unique(nan_counts);

    % build indicator matrix, so that a value in zeroed_samples(i,j)=v
    % - v=0 means that samples(i,j) is not NaN
    % - v=k, k>0 means that samples(i,j) is the k-th NaN value in that
    % column
    for c = 1:numel(unq_counts)
        count = unq_counts(c);
        col_msk = nan_counts == count;

        msk = bsxfun(@and, samples_nan_msk, col_msk);
        zeroed_samples(msk) = repmat((1:count)', 1, sum(col_msk));
    end

    % using the indicator matrix, split up the samples in blocks so that
    % in each block, the same rows in samples are NaN. Each block is
    % processed separately in the 'for' loop below.

    [indices, zeroed_unq] = cosmo_index_unique(zeroed_samples');

    nclasses = max(targets);

    for k = 1:numel(indices)
        zeroed = zeroed_unq(k, :)';
        cols = indices{k};

        nan_msk = zeroed > 0;

        % keep samples where there are non-NaN values for the corresponding
        % chunk
        keep_msk = any(bsxfun(@eq, chunks(:), chunks(~nan_msk)'), 2);
        keep_samples = samples(keep_msk, cols);

        % ensure chunks are numbered 1:max(chunks)
        [unused, unused, keep_chunks] = unique(chunks(keep_msk));

        % select targets
        keep_targets = targets(keep_msk);

        % compute t or F statistic
        [stat_k, df, stat_label_k] = stat_func(keep_samples, ...
                                               keep_targets, ...
                                               keep_chunks, ...
                                               type, contrast);

        % if for some samples in a chunk the value was NaN and for others
        % it was not NaN, then the output is NaN
        has_mixed_nan_and_non_nan = any(nan_msk(keep_msk));

        % if a target value was completely non-present, then
        % the output is NaN
        keep_targets_msk = false(1, nclasses);
        keep_targets_msk(targets(keep_msk)) = true;

        if has_mixed_nan_and_non_nan || any(~keep_targets_msk)
            stat_k(:) = NaN;
        end

        % store stat val
        stat(cols) = stat_k;

        if k == 1
            % allocate space
            df_count = numel(df);
            df_matrix = zeros(df_count, nfeatures);
            stat_label = stat_label_k;
        else
            % stat label must always be the same
            assert(isequal(stat_label, stat_label_k));
        end

        ncols = numel(cols);

        df_matrix(:, cols) = repmat(df(:), 1, ncols);
    end

    single_sample = samples(:, 1);
    single_sample(:) = 0;

    [unused, max_df] = stat_func(single_sample, targets, chunks, ...
                                 type, contrast);

    df_struct = struct();
    df_struct.max_df = max_df(:);
    df_struct.feature_wise_df = df_matrix;

function [stat, df_struct, stat_label] = apply_stat_func_no_nans(stat_func, ...
                                                                 samples, targets, chunks, ...
                                                                 type, contrast)
    % much faster computation of statistics, if there are no NaNs
    [stat, max_df, stat_label] = stat_func(samples, targets, chunks, ...
                                           type, contrast);

    nfeatures = size(samples, 2);

    df_struct = struct();
    df_struct.max_df = max_df(:);
    df_struct.feature_wise_df = repmat(max_df(:), 1, nfeatures);

function f = get_stat_func(stat_name)
    stat_name2func = struct();
    stat_name2func.t = @ttest1_wrapper;
    stat_name2func.t2 = @ttest2_wrapper;
    stat_name2func.F = @ftest_wrapper;

    assert(isfield(stat_name2func, stat_name));

    f = stat_name2func.(stat_name);

function verify_class_count(stat_name, targets)
    stat_name2interval = struct();
    stat_name2interval.t = [1, 2];
    stat_name2interval.t2 = [2, 2];
    stat_name2interval.F = [2, Inf];

    if ~isfield(stat_name2interval, stat_name)
        error('illegal statname ''%s'', supported are: %s', stat_name, ...
              cosmo_strjoin(fieldnames(stat_name2interval), ', '));
    end

    valid_interval = stat_name2interval.(stat_name);

    count = max(targets);
    assert(count == numel(unique(targets)));

    min_count = valid_interval(1);
    if count < min_count
        error('statname ''%s'' requires at least %d unique targets', ...
              stat_name, min_count);
    end

    max_count = valid_interval(2);
    if count > max_count
        error('statname ''%s'' requires at most %d unique targets', ...
              stat_name, max_count);
    end

function [stat, stat_label] = compute_output_stat(stat, df_struct, stat_name, ...
                                                  tail, output_stat_name)
    % transform output is required

    if isempty(output_stat_name)
        % raw t or F value
        stat_fa_name = stat_name;

        % features with missing values (as indicated by df values that
        % are different from the maximum df possible) are set to NaN
        df_is_max_msk = bsxfun(@eq, df_struct.max_df, ...
                               df_struct.feature_wise_df);
        has_missing = ~all(df_is_max_msk, 1);
        stat(has_missing) = NaN;

        % set degrees of freedom
        df_str = arrayfun(@(x) sprintf('%d', x), df_struct.max_df, ...
                          'UniformOutput', false);
        stat_label = sprintf('%s(%s)', stat_fa_name, ...
                             cosmo_strjoin(df_str, ','));
    else
        % transform to left-tailed p-value for each unique combination
        % of degrees of freedom
        stat = apply_cdf_wrapper_different_dfs(stat_name, stat, df_struct);

        switch output_stat_name
            case 'z'
                % transform to z-score
                stat = cosmo_norminv(stat);
                stat_fa_name = 'Zscore';
            case 'p'
                switch tail
                    case 'left'
                        % do nothing
                    case 'right'
                        % invert p-value
                        stat = 1 - stat;
                    case 'both'
                        % take whichever tail is more extreme
                        stat = (.5 - abs(stat - .5)) * 2;
                    otherwise
                        assert(false, 'this should not happen');
                end
                stat_fa_name = 'Pval';
            otherwise
                error('illegal output type %s', output_stat_name);
        end

        stat_label = sprintf('%s()', stat_fa_name);
    end

function cdf_stat = apply_cdf_wrapper_different_dfs(stat_name, stat, df_struct)
    % applies the cdf wrapper to each unique combination of degrees of
    % freedom stored in df_struct.feature_wise_df

    [idx, unique_dfs] = cosmo_index_unique(df_struct.feature_wise_df');

    % allocate space for output
    cdf_stat = zeros(size(stat)) + NaN;

    % compute result
    for k = 1:numel(idx)
        cols = idx{k};
        df_cell = num2cell(unique_dfs(k, :));

        col_stat = stat(cols);
        msk = ~isnan(col_stat);
        ps = cdf_wrapper(stat_name, col_stat(msk), df_cell{:});
        % In Octave, extreme statistics can lead to negative p values
        ps(ps < 0) = 0;
        ps(ps > 1) = 1;

        cdf_stat(cols(msk)) = ps;
    end

function y = cdf_wrapper(name, x, df1, df2)
    ensure_has_stats_functions();
    if ~(df1 > 0)
        y = zeros(size(x)) + NaN;
        return
    end

    switch name
        case 'Ttest'
            assert(nargin == 3);
            y = tcdf(x, df1);

        case 'Ftest'
            assert(nargin == 4);
            if ~(df2 > 0)
                y = zeros(size(x)) + NaN;
                return
            end

            y = fcdf(x, df1, df2);

        otherwise
            assert(false);
    end

function [stat, df, stat_label] = ttest1_wrapper(samples, targets, chunks, ...
                                                 type, contrast)
    if ~isempty(contrast)
        error('contrast is not supported for t-stat');
    end

    nclasses = max(targets);
    if nclasses == 2
        samples = pairwise_differences(samples, targets, chunks);
        nclasses = 1;
    end

    if nclasses ~= 1
        error('t-stat: expected 1 or 2 classes, found %d', ...
              nclasses);
    end

    [stat, df] = quick_ttest(samples);
    stat_label = 'Ttest';

function [stat, df, stat_label] = ttest2_wrapper(samples, targets, chunks, ...
                                                 type, contrast)
    if ~isempty(contrast)
        error('contrast is not supported for t-stat');
    end

    if strcmp(type, 'within')
        error(['ttest2 stat: the values in chunks and targets suggest '...
               'a within-subject design. If you want to '...
               'run a paired-test, use ''t'' (not ''t2'') ', ...
               'as the second argument']);
    end

    m1 = targets == 1;
    m2 = targets == 2;

    [stat, df] = quick_ttest2(samples(m1, :), ...
                              samples(m2, :));
    nclasses = max(targets);
    if nclasses ~= 2
        % missing targets, set all to NaN
        assert(nclasses < 2);
        stat(:) = NaN;
    end

    stat_label = 'Ttest';

function [stat, df, stat_label] = ftest_wrapper(samples, targets, chunks, ...
                                                type, contrast)
    nclasses = max(targets);

    if nclasses < 2
        error('F stat: expected >=2 classes, found %d', nclasses);
    end

    switch type
        case 'between'
            [stat, df] = quick_ftest_between(samples, targets, ...
                                             chunks, contrast);
        case 'within'
            [stat, df] = quick_ftest_within(samples, targets, ...
                                            chunks, contrast);

    end
    stat_label = 'Ftest';

function [f, df] = quick_ftest_between(samples, targets, chunks, contrast)
    % one-way ANOVA
    has_contrast = ~isempty(contrast);
    contrast_sum = 0;

    nclasses = max(targets);

    [ns, nf] = size(samples);
    mu = sum(samples, 1) / ns; % grand mean

    b = zeros(nclasses, nf); % between-class sum of squares
    nsc = zeros(nclasses, 1);
    wss = 0; % within-class sum of squares

    for k = 1:nclasses
        msk = k == targets;

        nsc(k) = sum(msk); % number of samples in this class
        sample = samples(msk, :);
        muc = sum(sample, 1) / nsc(k); % class mean

        % between- and within-class sum of squares
        if has_contrast
            cmsk = contrast(msk);
            if ~all(cmsk(1) == cmsk)
                error('Contrast has differerent values in level %d', k);
            end
            contrast_sum = contrast_sum + cmsk(1);
            b(k, :) = sum(bsxfun(@times, contrast(msk), mu - muc), 1);
        else
            b(k, :) = (mu - muc);
        end
        wss = wss + sum(bsxfun(@minus, muc, sample).^2, 1);
    end

    if has_contrast
        if contrast_sum ~= 0
            error('contrast has sum %d, should be 0', contrast_sum);
        end
        bss = sum(b, 1).^2 / sum(contrast.^2);
        df1 = 1;
    else
        bss = sum(bsxfun(@times, nsc, b.^2), 1);
        df1 = nclasses - 1;
    end

    df = [df1, ns - nclasses];

    mbss = bss / df(1);
    mwss = wss / df(2);

    f = zeros(1, nf) + NaN;
    msk = mbss > 0;
    f(msk) = mbss(msk) ./ mwss(msk);

function [f, df] = quick_ftest_within(samples, targets, chunks, contrast)
    % repeated measures anova
    if ~isempty(contrast)
        error('contrast is not supported for within-subject design');
    end

    nchunks = max(chunks);
    nclasses = max(targets);

    nfeatures = size(samples, 2);
    gm = mean(samples, 1); % grand mean

    sst = zeros(1, nfeatures);
    ssw = zeros(1, nfeatures);
    for k = 1:nclasses
        xk = samples(k == targets, :);
        n = size(xk, 1);

        if n == 0
            ssw(:) = NaN;
            break
        end

        mu = sum(xk, 1) / n;
        sst = sst + n * (gm - mu).^2;
        ssw = ssw + sum(bsxfun(@minus, mu, xk).^2);
    end

    sss = zeros(1, nfeatures);
    for k = 1:nchunks
        xk = samples(k == chunks, :);
        n = size(xk, 1);
        mu = mean(xk, 1);
        sss = sss + n * (gm - mu).^2;
    end

    df1 = (nclasses - 1);
    mst = sst / df1;

    df2 = df1 * (nchunks - 1);
    sse = ssw - sss;
    mse = sse / df2;

    msk = mse > 0;
    f = zeros(1, nfeatures) + NaN;
    f(msk) = mst(msk) ./ mse(msk);
    df = [df1 df2];

function [t, df] = quick_ttest(x)
    % one-sample t-test against zero

    [ns, nf] = size(x);
    mu = sum(x, 1) / ns; % grand mean

    df = ns - 1;
    scaling = ns * df;

    % sum of squares
    ss = sum(bsxfun(@minus, x, mu).^2, 1);

    t = zeros(1, nf) + NaN;
    msk = ss > 0;
    t(msk) = mu(msk) .* sqrt(scaling ./ ss(msk));

function [t, df] = quick_ttest2(x, y)
    % two-sample t-test with equal variance assumption

    [nx, nf] = size(x);
    ny = size(y, 1);

    df = nx + ny - 2;
    if nx == 0 || ny == 0
        t = zeros(1, nf) + NaN;
        return
    end

    mux = sum(x, 1) / nx; % mean of class x
    muy = sum(y, 1) / ny; % "           " y

    scaling = (nx * ny) * df / (nx + ny);

    % sum of squares
    ss = sum([bsxfun(@minus, x, mux); bsxfun(@minus, y, muy)].^2, 1);

    t = zeros(1, nf) + NaN;
    msk = ss > 0;
    t(msk) = (mux(msk) - muy(msk)) .* sqrt(scaling ./ ss(msk));

function ensure_has_stats_functions()
    % - Octave has the required functionality in the octave-forge
    %   statistics toolbox and will raise an error if it is not installed.
    % - Matlab will raise an error message that the statistics toolbox is
    %   required
    persistent cached_has_stat_funcs

    if isequal(cached_has_stat_funcs, [])
        if cosmo_wtf('is_matlab') ...
                    && isempty(which('tinv')) ...
                    && isempty(which('fcdf'))
            raise_exception_if_absent = true;
            cached_has_stat_funcs = cosmo_check_external('@stats', ...
                                                         raise_exception_if_absent);
        else
            cached_has_stat_funcs = false;
        end
    end

function [samples, targets, chunks, type] = get_descriptors(ds)
    cosmo_isfield(ds, {'samples', 'sa.chunks', 'sa.targets'}, true);

    samples = ds.samples;

    % unique targets
    [unused, unused, targets] = unique(ds.sa.targets);

    % unique chunks
    [unq_chunks, unused, chunks] = unique(ds.sa.chunks);
    nc = max(chunks);

    if isequal(sort(ds.sa.chunks), unq_chunks)
        type = 'between';
    else
        combis = (targets - 1) * nc + chunks;
        if isequal(sort(combis), (1:numel(combis))')
            type = 'within';
        else
            error(['Either all chunks must be unique, or each chunk '...
                   'must contain the same targets']);
        end
    end

function delta = pairwise_differences(samples, targets, chunks)
    % for one-sample t-test: compute differences between first and second
    % target
    n = size(samples, 1);
    assert(numel(targets) == n);
    assert(numel(chunks) == n);

    assert(mod(n, 2) == 0);
    n2 = n / 2;

    idxs = zeros(n2, 2);
    for k = 1:n
        assert(idxs(chunks(k), targets(k)) == 0);
        idxs(chunks(k), targets(k)) = k;
    end
    assert(isequal(size(idxs), [n2, 2]));
    assert(all(idxs(:) > 0));

    delta = samples(idxs(:, 1), :) - samples(idxs(:, 2), :);

function [output_stat_name, tail] = get_stat_definition(stat_name, ...
                                                        output_stat_name)
    if any(cosmo_match({'left', 'right', 'both'}, output_stat_name))
        tail = output_stat_name;
        output_stat_name = 'p';
    elseif strcmp(output_stat_name, 'p')
        switch stat_name
            case 'F'
                tail = 'right'; % show anova1  behaviour w.r.t. p-values
            otherwise
                tail = 'both'; % show ttest[2] "                       "
        end
    else
        tail = 'both';
    end