## Copyright (C) 2026 Andreas Bertsatos <abertsatos@biol.uoa.gr>
##
## This file is part of the statistics package for GNU Octave.
##
## This program is free software; you can redistribute it and/or modify it under
## the terms of the GNU General Public License as published by the Free Software
## Foundation; either version 3 of the License, or (at your option) any later
## version.
##
## This program is distributed in the hope that it will be useful, but WITHOUT
## ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
## FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
## details.
##
## You should have received a copy of the GNU General Public License along with
## this program; if not, see <http://www.gnu.org/licenses/>.

## -*- texinfo -*-
## @deftypefn  {statistics} {@var{mdl} =} stepwiseglm (@var{X}, @var{y})
## @deftypefnx {statistics} {@var{mdl} =} stepwiseglm (@var{X}, @var{y}, @var{modelspec})
## @deftypefnx {statistics} {@var{mdl} =} stepwiseglm (@var{tbl})
## @deftypefnx {statistics} {@var{mdl} =} stepwiseglm (@var{tbl}, @var{modelspec})
## @deftypefnx {statistics} {@var{mdl} =} stepwiseglm (@dots{}, @var{Name}, @var{Value})
##
## Fit a generalized linear regression model by stepwise term selection.
##
## @code{@var{mdl} = stepwiseglm (@var{X}, @var{y})} starts from a model given
## by @var{modelspec} and repeatedly adds or removes terms, one at a time, until
## no further move improves the selection criterion.  It returns the fitted
## @code{GeneralizedLinearModel} object @var{mdl}, whose @code{Steps} property
## records the term-selection trace.  @var{X} is an @math{n}-by-@math{p} numeric
## predictor matrix and @var{y} the response; @code{@var{mdl} = stepwiseglm
## (@var{tbl})} instead takes the predictors and response from the table
## @var{tbl} (the last column is the response unless overridden).
##
## @var{modelspec} is the @emph{starting} model.  It is a Wilkinson formula
## string (e.g.@: @qcode{'y ~ x1 + x2'}), a keyword (@qcode{'constant'}
## (default), @qcode{'linear'}, @qcode{'interactions'}, @qcode{'purequadratic'},
## @qcode{'quadratic'}, or @qcode{'full'}), or a terms matrix.  The candidate
## terms available to the search are bounded below by @qcode{'Lower'} and above
## by @qcode{'Upper'}.
##
## The following @var{Name}/@var{Value} pairs control the stepwise search:
##
## @multitable @columnfractions 0.2 0.75
## @headitem Name @tab Value
## @item @qcode{'Lower'} @tab the smallest model considered (terms in it are
## never removed).  Defaults to @qcode{'constant'}.
## @item @qcode{'Upper'} @tab the largest model considered (the candidate term
## universe).  Defaults to @qcode{'interactions'}.
## @item @qcode{'Criterion'} @tab the selection criterion: @qcode{'Deviance'}
## (default), @qcode{'sse'}, @qcode{'aic'}, or @qcode{'bic'}.  Under
## @qcode{'Deviance'} and @qcode{'sse'}, terms enter or leave by a chi-squared
## or @math{F} test on the change in deviance; under @qcode{'aic'}/@qcode{'bic'}
## the move that most reduces the information criterion is taken.
## @item @qcode{'PEnter'} @tab the @math{p}-value (or criterion margin) below
## which a term is added.  Defaults to @math{0.05} for @qcode{'Deviance'} and
## @qcode{'sse'}, and @math{0} for @qcode{'aic'}/@qcode{'bic'}.
## @item @qcode{'PRemove'} @tab the @math{p}-value (or criterion margin) above
## which a term is removed.  Defaults to @math{0.10} for @qcode{'Deviance'} and
## @qcode{'sse'}, and @math{0} for @qcode{'aic'}/@qcode{'bic'}.
## @item @qcode{'NSteps'} @tab the maximum number of steps.  Defaults to
## @code{Inf} (run to convergence).
## @item @qcode{'Verbose'} @tab @math{0} to run silently, or @math{1} (default)
## to print each accepted step.
## @end multitable
##
## As in @code{fitglm}, a @qcode{'binomial'} response is the number of
## successes, and the trials come either from @qcode{'BinomialSize'} or from
## passing @var{y} as an @math{n}-by-@math{2} matrix of successes and trials.
## @strong{Changed in 1.9.0}: @var{y} was previously read as the proportion.
##
## All @var{Name}/@var{Value} pairs accepted by @code{fitglm} (such as
## @qcode{'Distribution'}, @qcode{'Link'}, @qcode{'Weights'}, @qcode{'Offset'},
## @qcode{'BinomialSize'}, @qcode{'Intercept'}, @qcode{'DispersionFlag'},
## @qcode{'CategoricalVars'}, and @qcode{'Exclude'}) are also accepted and
## forwarded to the fit.
##
## @subsubheading The @code{Steps} property
##
## The returned model's @code{Steps} property records the trace, as a
## structure with seven fields.  @code{Start}, @code{Lower}, and @code{Upper}
## are @code{LinearFormula} objects for the starting model and the two bounds;
## @code{Criterion} is the criterion as it was asked for; @code{PEnter} and
## @code{PRemove} are the thresholds it ran under; and @code{History} is a
## table with one row per step.
##
## @code{History} always carries @code{Action} (@qcode{'Start'},
## @qcode{'Add'}, or @qcode{'Remove'}), @code{TermName}, @code{Terms} (the
## terms matrix after the step, over the model's variables), @code{DF} (the
## coefficient count after the step), and @code{delDF} (the change in it,
## @emph{negative} for a removal).  The remaining columns follow the
## criterion, which is why a history is read by name and not by position:
##
## @multitable @columnfractions 0.25 0.7
## @headitem Criterion @tab Further columns
## @item @qcode{'Deviance'} @tab @code{Deviance}, then @code{Chi2Stat} or
## @code{FStat} as the dispersion is fixed or estimated, then @code{PValue}.
## @item @qcode{'sse'} @tab @code{FStat} and @code{pValue}.
## @item @qcode{'aic'}, @qcode{'bic'} @tab one column, @code{AIC} or
## @code{BIC}, holding the criterion's value after the step, the starting
## model included.
## @end multitable
##
## The first row is the starting model, named by its right-hand side.
##
## @subsubheading Categorical predictors
##
## A categorical predictor with @math{L} levels contributes @math{L - 1}
## indicator columns, the first level being the omitted reference, and the
## search treats that whole group as a @emph{single term}: it is added or
## removed in one step, worth @math{L - 1} degrees of freedom, and never one
## indicator at a time.  An interaction naming a categorical predictor behaves
## the same way, contributing one column per indicator and entering as one
## term.  @code{Steps.History} names such a term by the predictor
## (@qcode{'g'}, or @qcode{'x1:g'} for the interaction) rather than by its
## indicators, while @code{CoefficientNames} names the indicators
## (@qcode{'g_B'}, @qcode{'x1:g_C'}).
##
## In a table, every column that groups its observations is taken as
## categorical: a cell array of character vectors, a @code{categorical} array,
## a string array, or a logical column.  @qcode{'CategoricalVars'} adds to
## these, and is the only way to mark a column of a predictor @emph{matrix};
## it takes predictor names, column indices, or a logical vector.
##
## @seealso{GeneralizedLinearModel, fitglm, stepwisefit, glmfit, glmval}
## @end deftypefn

function mdl = stepwiseglm (varargin)

  if (nargin < 1)
    print_usage ();
  endif

  ## ------------------------------------------------------------------------ ##
  ## Separate the data/response, the (optional) starting model, and the pairs.
  ## ------------------------------------------------------------------------ ##
  arg1 = varargin{1};
  if (istable (arg1))
    data = arg1;
    resp = [];
    rest = varargin(2:end);
  else
    if (nargin < 2)
      print_usage ();
    endif
    data = arg1;
    resp = varargin{2};
    rest = varargin(3:end);
  endif

  start_spec = 'constant';
  if (! isempty (rest))
    a = rest{1};
    if ((ischar (a) && ! is_param_name (a)) || isnumeric (a))
      start_spec = a;
      rest       = rest(2:end);
    endif
  endif

  ## Split the pairs into stepwise controls and forwarded fitglm options.
  [sw, opts, glmnv] = parse_pairs (rest);

  ## ------------------------------------------------------------------------ ##
  ## Intake: numeric predictor matrix, response, names, and fitting subset.
  ## ------------------------------------------------------------------------ ##
  ## Trial counts taken from a two-column binomial response, empty otherwise.
  binom_2col = [];

  start_is_formula = ischar (start_spec) && any (start_spec == '~');
  if (istable (data))
    col_names = data.Properties.VariableNames;
    if (ischar (resp) && ! isempty (resp))
      resp_name = resp;
    elseif (! isempty (opts.ResponseVar))
      resp_name = opts.ResponseVar;
    elseif (start_is_formula)
      resp_name = strtrim (strsplit (start_spec, '~'){1});
    else
      resp_name = col_names{end};
    endif
    if (! isempty (opts.PredictorVars))
      pred_names = opts.PredictorVars;
    else
      pred_names = col_names(! strcmp (col_names, resp_name));
    endif
    p = numel (pred_names);
    ## Filled in by RAW_TO_CODES below, which reads the table itself.
    X = zeros (height (data), p);
    y = double (data.(resp_name)(:));
  else
    if (! (isnumeric (data) && isreal (data) && ismatrix (data)))
      error ("stepwiseglm: X must be a real matrix.");
    endif
    ## A binomial response may be given as an N-by-2 matrix of successes and
    ## trials, as MATLAB accepts and as GeneralizedLinearModel accepts; the
    ## trials are carried alongside and the final model is handed the original
    ## response, which validates it.
    is_2col = strcmp (opts.Distribution, 'binomial') && isnumeric (resp) ...
              && isreal (resp) && ismatrix (resp) && ! isvector (resp) ...
              && columns (resp) == 2;
    if (! (is_2col || (isnumeric (resp) && isreal (resp) && isvector (resp))))
      error ("stepwiseglm: Y must be a real vector.");
    endif
    X = double (data);
    if (is_2col)
      binom_2col = double (resp(:,2));
      y          = double (resp(:,1));
    else
      y = double (resp(:));
    endif
    p = columns (X);
    if (rows (X) != numel (y))
      error (strcat ("stepwiseglm: X and Y must have the same number of", ...
                     " observations."));
    endif
    if (! isempty (opts.VarNames))
      pred_names = opts.VarNames(1:p)(:)';
      resp_name  = opts.VarNames{end};
    else
      pred_names = arrayfun (@(k) sprintf ("x%d", k), 1:p, ...
                             'UniformOutput', false);
      resp_name  = 'y';
    endif
    if (! isempty (opts.ResponseVar))
      resp_name = opts.ResponseVar;
    endif
  endif

  ## ------------------------------------------------------------------------ ##
  ## Categorical predictors.  Each is coded 1..L and expanded to L-1 indicator
  ## columns, exactly as GeneralizedLinearModel encodes them, so that the final
  ## model refits the design the search scored.  The search itself stays over
  ## the model's variables, which is what makes a categorical enter and leave
  ## as one term of L-1 degrees of freedom rather than one indicator at a time.
  ## ------------------------------------------------------------------------ ##
  cat_cols = categorical_flags (opts.CategoricalVars, pred_names, p, data);
  [X, cat_levels] = raw_to_codes (data, X, data, pred_names, cat_cols, ...
                                  rows (X));
  [X_enc, enc_names, cat_info] = encode_categorical (X, cat_cols, ...
                                   pred_names, cat_levels, opts.Intercept);

  ## Distribution and criterion validation.
  known_distr = {'normal', 'binomial', 'poisson', 'gamma', ...
                 'inverse gaussian'};
  distr = opts.Distribution;
  if (! any (strcmp (distr, known_distr)))
    error ("stepwiseglm: unknown distribution '%s'.", distr);
  endif
  crit = lower (sw.Criterion);
  if (! any (strcmp (crit, {'deviance', 'sse', 'aic', 'bic'})))
    error ("stepwiseglm: unknown criterion '%s'.", sw.Criterion);
  endif

  ## Fitting subset (drop missing and excluded rows).
  missing_mask  = any (isnan (X), 2) | isnan (y);
  excluded_mask = false (rows (X), 1);
  if (! isempty (opts.Exclude))
    ex = opts.Exclude(:);
    if (islogical (ex))
      excluded_mask(1:numel (ex)) = ex;
    else
      excluded_mask(ex) = true;
    endif
  endif
  subset = ! missing_mask & ! excluded_mask;
  if (! any (subset))
    error (strcat ("stepwiseglm: no observations remain after removing", ...
                   " missing/excluded rows."));
  endif
  Xs = X_enc(subset, :);
  ys = y(subset);
  n  = rows (Xs);

  ## Weights, offset, and binomial trial counts, subset to the fitting rows.
  w_sub = [];
  if (! isempty (opts.Weights))
    w_sub = double (opts.Weights(:))(subset);
  endif
  off_sub = [];
  if (! isempty (opts.Offset))
    off_sub = double (opts.Offset(:))(subset);
  endif
  N_sub = [];
  N_all = [];
  if (strcmp (distr, 'binomial'))
    if (! isempty (binom_2col))
      ## The trials came with the response and win over 'BinomialSize'.
      N_all = binom_2col;
    elseif (! isempty (opts.BinomialSize))
      N_all = opts.BinomialSize(:);
      if (isscalar (N_all))
        N_all = N_all * ones (rows (X), 1);
      endif
    endif
  endif
  if (! isempty (N_all))
    N_sub = N_all(subset);
  endif
  w_ll = w_sub;
  if (isempty (w_ll))
    w_ll = ones (n, 1);
  endif

  ## Link function and glmfit argument list (shared by every candidate fit).
  if (! isempty (opts.Link))
    linkspec = opts.Link;
  else
    linkspec = default_link (distr);
  endif
  [~, ~, ilink] = getlinkfunctions (linkspec);
  gargs = {'link', linkspec, 'constant', 'off'};
  if (! isempty (w_sub))
    gargs = [gargs, {'weights', w_sub}];
  endif
  if (! isempty (off_sub))
    gargs = [gargs, {'offset', off_sub}];
  endif
  if (! isempty (opts.DispersionFlag))
    if (opts.DispersionFlag)
      gargs = [gargs, {'estdisp', 'on'}];
    else
      gargs = [gargs, {'estdisp', 'off'}];
    endif
  endif
  yfit = ys;
  if (strcmp (distr, 'binomial') && ! isempty (N_sub))
    ## YS is the number of successes: GLMFIT takes [successes, trials], while
    ## the log-likelihood below takes the proportion.
    yfit = [ys, N_sub];
    ys   = ys ./ N_sub;
  endif

  ## Whether dispersion is estimated (chooses the F- vs chi-squared test and
  ## the internal criterion label).
  if (! isempty (opts.DispersionFlag))
    estdisp = logical (opts.DispersionFlag);
  else
    estdisp = any (strcmp (distr, {'normal', 'gamma', 'inverse gaussian'}));
  endif

  ## Package the fitting context passed to the inner fitter.
  ctx = struct ('X', Xs, 'yfit', yfit, 'y', ys, 'distr', distr, ...
                'gargs', {gargs}, 'ilink', ilink, 'off', off_sub, ...
                'N', N_sub, 'w', w_ll, 'n', n, 'p', p, ...
                'pred_names', {pred_names}, 'cat_info', cat_info, ...
                'enc_names', {enc_names});

  ## ------------------------------------------------------------------------ ##
  ## Resolve the starting, lower, and upper models to terms matrices.
  ## ------------------------------------------------------------------------ ##
  T_start = resolve_terms (start_spec, pred_names, p, opts.Intercept);
  T_lower = resolve_terms (sw.Lower, pred_names, p, opts.Intercept);
  T_upper = resolve_terms (sw.Upper, pred_names, p, opts.Intercept);

  ## The search operates within [Lower, Upper]; the start must contain Lower.
  T = union_rows (T_start, T_lower);

  ## Entry/removal thresholds.
  if (any (strcmp (crit, {'deviance', 'sse'})))
    penter  = ternary (isempty (sw.PEnter),  0.05, sw.PEnter);
    premove = ternary (isempty (sw.PRemove), 0.10, sw.PRemove);
  else
    penter  = ternary (isempty (sw.PEnter),  0, sw.PEnter);
    premove = ternary (isempty (sw.PRemove), 0, sw.PRemove);
  endif

  ## ------------------------------------------------------------------------ ##
  ## Stepwise search.
  ## ------------------------------------------------------------------------ ##
  ws = warning ();                # silence per-candidate glmfit warnings
  warning ("off", "all");
  unwind_protect

    f0      = fit_terms (T, ctx);
    if (any (strcmp (crit, {'aic', 'bic'})))
      start_stat = crit_value (f0, crit, ctx);
    else
      start_stat = NaN;
    endif
    hist    = start_history (T, f0.dev, f0.ncoef, ...
                             start_name (T, pred_names, p), start_stat);
    stepnum = 0;
    do
      stepnum += 1;
      [T, hstep] = one_step (T, T_lower, T_upper, ctx, p, crit, estdisp, ...
                             penter, premove, pred_names);
      if (isempty (hstep))
        break;
      endif
      hist(end+1) = hstep;
      if (sw.Verbose)
        print_step (stepnum, hstep, crit, estdisp, pred_names, p);
      endif
    until (stepnum >= sw.NSteps)

  unwind_protect_cleanup
    warning (ws);
  end_unwind_protect

  ## ------------------------------------------------------------------------ ##
  ## Build the final object (canonical term order) and attach the trace.
  ## ------------------------------------------------------------------------ ##
  T_final = canonical_sort (T, p);
  ## With categorical predictors the constructor reads a terms matrix over the
  ## encoded columns, so the selected terms are expanded and the resolved names
  ## are handed over -- an index or a table column detected here would
  ## otherwise be re-derived, and need not agree.
  if (isempty (cat_info.names))
    spec_final  = T_final;
    glmnv_final = glmnv;
  else
    spec_final  = expand_terms (T_final, ctx);
    glmnv_final = set_categorical (glmnv, cat_info.names);
  endif
  mdl = GeneralizedLinearModel (data, resp, spec_final, glmnv_final{:});

  var_names = [pred_names, {resp_name}];
  steps = struct ();
  steps.Start     = LinearFormula (T_start, var_names, 'ResponseName', ...
                                   resp_name, 'Link', linkspec);
  steps.Lower     = LinearFormula (T_lower, var_names, 'ResponseName', ...
                                   resp_name, 'Link', linkspec);
  steps.Upper     = LinearFormula (T_upper, var_names, 'ResponseName', ...
                                   resp_name, 'Link', linkspec);
  steps.Criterion = criterion_label (crit, estdisp);
  steps.PEnter    = sw.PEnter;
  steps.PRemove   = sw.PRemove;
  steps.History   = history_table (hist, crit, estdisp, pred_names, p);
  mdl = setSteps (mdl, steps);

endfunction

## ========================================================================== ##
## Argument parsing
## ========================================================================== ##

## True if S names a stepwiseglm or forwarded fitglm parameter.
function tf = is_param_name (s)
  tf = ischar (s) && any (strcmpi (s, {'Lower', 'Upper', 'Criterion', ...
       'PEnter', 'PRemove', 'NSteps', 'Verbose', 'Distribution', 'Link', ...
       'Weights', 'Offset', 'BinomialSize', 'Intercept', 'DispersionFlag', ...
       'CategoricalVars', 'Exclude', 'VarNames', 'PredictorVars', ...
       'ResponseVar'}));
endfunction

## Split Name/Value pairs into stepwise controls (SW), the fit options this
## function needs (OPTS), and the raw pairs forwarded to GeneralizedLinearModel
## (GLMNV).
function [sw, opts, glmnv] = parse_pairs (pairs)
  sw = struct ('Lower', 'constant', 'Upper', 'interactions', ...
               'Criterion', 'Deviance', 'PEnter', [], 'PRemove', [], ...
               'NSteps', Inf, 'Verbose', 1);
  opts = struct ('Distribution', 'normal', 'Link', [], 'Weights', [], ...
                 'Offset', [], 'BinomialSize', [], 'DispersionFlag', [], ...
                 'CategoricalVars', [], 'Exclude', [], 'Intercept', true, ...
                 'VarNames', {{}}, 'PredictorVars', [], 'ResponseVar', []);
  glmnv = {};
  if (mod (numel (pairs), 2) != 0)
    error ("stepwiseglm: Name/Value arguments must come in pairs.");
  endif
  for k = 1:2:numel (pairs)
    name = pairs{k};
    val  = pairs{k+1};
    if (! ischar (name))
      error ("stepwiseglm: parameter names must be character vectors.");
    endif
    switch (lower (name))
      case 'lower'
        sw.Lower = val;
      case 'upper'
        sw.Upper = val;
      case 'criterion'
        sw.Criterion = val;
      case 'penter'
        sw.PEnter = val;
      case 'premove'
        sw.PRemove = val;
      case 'nsteps'
        sw.NSteps = val;
      case 'verbose'
        sw.Verbose = val;
      otherwise
        ## Fit option: record what we need and forward the pair unchanged.
        switch (lower (name))
          case 'distribution';    opts.Distribution   = lower (val);
          case 'link';            opts.Link           = val;
          case 'weights';         opts.Weights        = val;
          case 'offset';          opts.Offset         = val;
          case 'binomialsize';    opts.BinomialSize   = val;
          case 'dispersionflag';  opts.DispersionFlag = val;
          case 'categoricalvars'; opts.CategoricalVars = val;
          case 'exclude';         opts.Exclude        = val;
          case 'intercept';       opts.Intercept      = val;
          case 'varnames';        opts.VarNames       = val;
          case 'predictorvars';   opts.PredictorVars  = val;
          case 'responsevar';     opts.ResponseVar    = val;
          otherwise
            error ("stepwiseglm: unknown parameter name '%s'.", name);
        endswitch
        glmnv = [glmnv, {name, val}];
    endswitch
  endfor
endfunction

## ========================================================================== ##
## Term-selection engine
## ========================================================================== ##

## Perform one step: try to add, then to remove.  Returns the updated terms T
## and a history record HSTEP ([] if no move was accepted).
function [T, hstep] = one_step (T, T_lower, T_upper, ctx, p, crit, estdisp, ...
                                penter, premove, pred_names)
  hstep = [];
  fC    = fit_terms (T, ctx);

  if (any (strcmp (crit, {'deviance', 'sse'})))
    ## --- Add phase: pick the addable term with the smallest p-value. -------
    addable = candidates_add (T, T_upper, p);
    best_p = Inf;  best = [];
    for i = 1:rows (addable)
      r  = addable(i, :);
      fN = fit_terms ([T; r], ctx);
      [stat, pval, ddf] = nested_test (fC.dev, fC.dfe, fN.dev, fN.dfe, estdisp);
      if (pval < best_p)
        best_p = pval;
        best   = struct ('row', r, 'stat', stat, 'pval', pval, ...
                         'ddf', ddf, 'dev', fN.dev, 'df', fN.ncoef);
      endif
    endfor
    if (! isempty (best) && best_p < penter)
      T     = [T; best.row];
      hstep = mkhist ('Add', best.row, T, best.df, best.ddf, best.dev, ...
                      best.stat, best.pval, p);
      return;
    endif

    ## --- Remove phase: pick the removable term with the largest p-value. ---
    removable = candidates_remove (T, T_lower, p);
    worst_p = -Inf;  worst = [];
    for i = 1:rows (removable)
      r  = removable(i, :);
      Tn = remove_row (T, r);
      fN = fit_terms (Tn, ctx);
      [stat, pval, ddf] = nested_test (fN.dev, fN.dfe, fC.dev, fC.dfe, estdisp);
      if (pval > worst_p)
        worst_p = pval;
        worst   = struct ('row', r, 'stat', stat, 'pval', pval, ...
                          'ddf', ddf, 'dev', fN.dev, 'df', fN.ncoef, 'Tn', Tn);
      endif
    endfor
    if (! isempty (worst) && worst_p > premove)
      T     = worst.Tn;
      hstep = mkhist ('Remove', worst.row, T, worst.df, -worst.ddf, ...
                      worst.dev, worst.stat, worst.pval, p);
    endif

  else
    ## --- Information criterion: take the single best neighbouring move. ----
    curval  = crit_value (fC, crit, ctx);
    best_val = curval;  best = [];
    addable = candidates_add (T, T_upper, p);
    for i = 1:rows (addable)
      r  = addable(i, :);
      fN = fit_terms ([T; r], ctx);
      v  = crit_value (fN, crit, ctx);
      if (v < best_val - 1e-12)
        best_val = v;
        best = struct ('row', r, 'act', 'Add', 'T', [T; r], 'dev', fN.dev, ...
                       'df', fN.ncoef, 'ddf', fN.ncoef - fC.ncoef, 'val', v);
      endif
    endfor
    removable = candidates_remove (T, T_lower, p);
    for i = 1:rows (removable)
      r  = removable(i, :);
      Tn = remove_row (T, r);
      fN = fit_terms (Tn, ctx);
      v  = crit_value (fN, crit, ctx);
      if (v < best_val - 1e-12)
        best_val = v;
        best = struct ('row', r, 'act', 'Remove', 'T', Tn, 'dev', fN.dev, ...
                       'df', fN.ncoef, 'ddf', fN.ncoef - fC.ncoef, 'val', v);
      endif
    endfor
    if (! isempty (best))
      T     = best.T;
      hstep = mkhist (best.act, best.row, T, best.df, best.ddf, best.dev, ...
                      best.val, NaN, p);
    endif
  endif
endfunction

## Fit a candidate model given by terms matrix T.
function f = fit_terms (T, ctx)
  T_enc = expand_terms (T, ctx);
  Xd    = build_design (T_enc, ctx.X);
  [b, dev, stats] = glmfit (Xd, ctx.yfit, ctx.distr, ctx.gargs{:});
  f = struct ('b', b, 'dev', dev, 'dfe', stats.dfe, 's', stats.s, ...
              'ncoef', rows (T_enc), 'X', Xd);
endfunction

## Nested-model test between the smaller (A) and larger (B) model.  Returns the
## chi-squared or F statistic, its p-value, and the degrees of freedom change.
function [stat, pval, ddf] = nested_test (devA, dfeA, devB, dfeB, estdisp)
  ddf  = dfeA - dfeB;             # number of coefficients that differ (>= 1)
  drop = devA - devB;            # deviance reduction from A to B
  if (estdisp)
    stat = (drop / ddf) / (devB / dfeB);
    pval = fcdf (stat, ddf, dfeB, 'upper');
  else
    stat = drop;
    pval = chi2cdf (stat, ddf, 'upper');
  endif
endfunction

## Information-criterion value (AIC or BIC) of a fitted candidate.
function v = crit_value (f, crit, ctx)
  eta = f.X * f.b;
  if (! isempty (ctx.off))
    eta = eta + ctx.off;
  endif
  mu = ctx.ilink (eta);
  LL = glm_loglik (ctx.distr, ctx.y, mu, ctx.N, ctx.w, f.s);
  k  = f.ncoef;
  switch (crit)
    case 'aic'
      v = -2 * LL + 2 * k;
    case 'bic'
      v = -2 * LL + k * log (ctx.n);
  endswitch
endfunction

## ========================================================================== ##
## Candidate generation and hierarchy
## ========================================================================== ##

## Rows of the upper model that may be added to T (not present and with every
## one-step-down parent present, so the model hierarchy is preserved).
function C = candidates_add (T, T_upper, p)
  C = zeros (0, columns (T));
  for i = 1:rows (T_upper)
    r = T_upper(i, :);
    if (row_member (r, T))
      continue;
    endif
    if (parents_present (r, T, p))
      C = [C; r];
    endif
  endfor
endfunction

## Rows of T that may be removed (not the intercept, not in the lower model,
## and not a required parent of any higher-order term present in T).
function C = candidates_remove (T, T_lower, p)
  C = zeros (0, columns (T));
  for i = 1:rows (T)
    r = T(i, :);
    if (all (r(1:p) == 0))         # intercept
      continue;
    endif
    if (row_member (r, T_lower))
      continue;
    endif
    if (has_superset (r, T, p))
      continue;
    endif
    C = [C; r];
  endfor
endfunction

## True if every one-step-down parent of term R is present in T.
function tf = parents_present (r, T, p)
  tf = true;
  for j = find (r(1:p) != 0)
    par     = r;
    par(j) -= 1;
    if (any (par(1:p) != 0) && ! row_member (par, T))
      tf = false;
      return;
    endif
  endfor
endfunction

## True if some other row of T is a strict superset of term R.
function tf = has_superset (r, T, p)
  tf = false;
  for i = 1:rows (T)
    s = T(i, :);
    if (isequal (s, r))
      continue;
    endif
    if (all (s(1:p) >= r(1:p)) && any (s(1:p) > r(1:p)))
      tf = true;
      return;
    endif
  endfor
endfunction

function tf = row_member (r, T)
  tf = ! isempty (T) && any (all (T == r, 2));
endfunction

function T = remove_row (T, r)
  T(all (T == r, 2), :) = [];
endfunction

function U = union_rows (A, B)
  U = A;
  for i = 1:rows (B)
    if (! row_member (B(i, :), U))
      U = [U; B(i, :)];
    endif
  endfor
endfunction

## ========================================================================== ##
## Categorical predictors
## ========================================================================== ##

## Which predictors are categorical.  An explicit 'CategoricalVars' may name
## them, index them, or mark them with a logical vector; a table additionally
## contributes every column whose class groups its observations.
function cat_cols = categorical_flags (cv, pred_names, p, data)
  cat_cols = false (1, p);
  if (! isempty (cv))
    if (islogical (cv))
      n = min (numel (cv), p);
      cat_cols(1:n) = cv(1:n);
    elseif (isnumeric (cv))
      cat_cols(cv(cv > 0 & cv <= p)) = true;
    elseif (iscellstr (cv) || ischar (cv))
      cv = cellstr (cv);
      for i = 1:numel (cv)
        j = find (strcmp (pred_names, cv{i}));
        if (isempty (j))
          error ("stepwiseglm: unknown categorical predictor '%s'.", cv{i});
        endif
        cat_cols(j) = true;
      endfor
    else
      error (strcat ("stepwiseglm: 'CategoricalVars' must be predictor", ...
                     " names, indices, or a logical vector."));
    endif
  endif
  if (istable (data))
    for j = 1:p
      col = data.(pred_names{j});
      if (iscell (col) || isa (col, 'categorical') || islogical (col) ...
          || isa (col, 'string'))
        cat_cols(j) = true;
      endif
    endfor
  endif
endfunction

## Expand a terms matrix over the P predictors into one over the encoded
## indicator columns.  A term naming a categorical predictor becomes one row
## per indicator, so the whole group is fitted, added, and dropped together.
function T_enc = expand_terms (T, ctx)
  if (isempty (ctx.cat_info.names))
    T_enc = T;
    return;
  endif
  T_enc = zeros (0, numel (ctx.enc_names) + 1);
  for r = 1:rows (T)
    G     = expand_term_row (T(r, 1:ctx.p), ctx);
    T_enc = [T_enc; G, zeros(rows (G), 1)];
  endfor
endfunction

## The encoded rows of one term.  Two categorical factors in an interaction
## multiply out, giving one row per pair of indicators.
function G = expand_term_row (row, ctx)
  p_enc = numel (ctx.enc_names);
  G     = zeros (1, p_enc);
  for j = find (row != 0)
    ci = find (strcmp (ctx.cat_info.names, ctx.pred_names{j}), 1);
    if (isempty (ci))
      G(:, find (strcmp (ctx.enc_names, ctx.pred_names{j}), 1)) = row(j);
    else
      cols = indicator_cols (ctx.pred_names{j}, ctx.cat_info.levels{ci}, ...
                             ctx.enc_names);
      Gn = zeros (rows (G) * numel (cols), p_enc);
      k  = 0;
      for i = 1:rows (G)
        for c = cols
          k += 1;
          Gn(k, :) = G(i, :);
          Gn(k, c) = 1;
        endfor
      endfor
      G = Gn;
    endif
  endfor
endfunction

## Encoded columns of a categorical predictor.  The reference level has no
## column of its own unless the predictor stood in for a dropped intercept, so
## the levels are looked up by name rather than assumed to start at the second.
function cols = indicator_cols (name, levels, enc_names)
  cols = [];
  for L = 1:numel (levels)
    c = find (strcmp (enc_names, [name, '_', char(levels{L})]), 1);
    if (! isempty (c))
      cols(end+1) = c;
    endif
  endfor
endfunction

## Replace any 'CategoricalVars' pair forwarded to the constructor with the
## resolved predictor names.
function nv = set_categorical (nv, names)
  keep = true (1, numel (nv));
  for k = 1:2:numel (nv) - 1
    if (strcmpi (nv{k}, 'CategoricalVars'))
      keep(k:k+1) = false;
    endif
  endfor
  nv = [nv(keep), {'CategoricalVars', names}];
endfunction

## ========================================================================== ##
## Spec resolution and term naming
## ========================================================================== ##

## Resolve a model specification (keyword, terms matrix, or formula) to a terms
## matrix over the P predictors, with the trailing response column.
function T = resolve_terms (spec, pred_names, p, intercept)
  if (ischar (spec) && any (spec == '~'))
    res    = parseWilkinsonFormula (spec, 'expand');
    model  = res.model;
    T      = zeros (0, p + 1);
    has_int = false;
    for i = 1:numel (model)
      if (isempty (model{i}))
        has_int = true;
      else
        row = term_row (model{i}, pred_names, p);
        T   = [T; row];
      endif
    endfor
    if (has_int && intercept)
      T = [zeros(1, p + 1); T];
    endif
  else
    [T, ~, ~, emsg] = parse_modelspec (spec, pred_names, p, intercept);
    if (! isempty (emsg))
      error ("stepwiseglm: %s", emsg);
    endif
  endif
endfunction

## Build a terms-matrix row from a cell of factor strings (e.g. {'x1','x2^2'}).
function row = term_row (factors, pred_names, p)
  row = zeros (1, p + 1);
  for k = 1:numel (factors)
    f   = factors{k};
    pw  = 1;
    hat = strfind (f, '^');
    if (! isempty (hat))
      pw = str2double (f(hat(1)+1:end));
      f  = f(1:hat(1)-1);
    endif
    j = find (strcmp (pred_names, f));
    if (isempty (j))
      error ("stepwiseglm: unknown predictor '%s' in formula.", f);
    endif
    row(j) += pw;
  endfor
endfunction

## Display name of a terms-matrix row (e.g. 'x1:x2', 'x3^2', '(Intercept)').
function nm = term_name (r, pred_names, p)
  if (all (r(1:p) == 0))
    nm = '(Intercept)';
    return;
  endif
  parts = {};
  for j = find (r(1:p) != 0)
    if (r(j) == 1)
      parts{end+1} = pred_names{j};
    else
      parts{end+1} = sprintf ("%s^%d", pred_names{j}, r(j));
    endif
  endfor
  nm = strjoin (parts, ':');
endfunction

## Wilkinson-style formula string for a terms matrix (used for Steps.Start etc).
function s = terms_formula (T, resp_name, pred_names, p)
  has_int = false;
  parts   = {};
  for i = 1:rows (T)
    if (all (T(i, 1:p) == 0))
      has_int = true;
    else
      parts{end+1} = term_name (T(i, :), pred_names, p);
    endif
  endfor
  if (has_int)
    rhs = strjoin ([{'1'}, parts], ' + ');
  elseif (isempty (parts))
    rhs = '1';
  else
    rhs = strjoin (parts, ' + ');
  endif
  s = sprintf ("%s ~ %s", resp_name, rhs);
endfunction

## Sort terms into canonical order: intercept first, then by degree, then by
## the sorted list of predictor indices (matching fitglm's coefficient order).
function T = canonical_sort (T, p)
  m   = rows (T);
  deg = zeros (m, 1);
  idx = zeros (m, p);            # padded index lists
  for i = 1:m
    lst = [];
    for j = find (T(i, 1:p) != 0)
      lst = [lst, repmat(j, 1, T(i, j))];
    endfor
    deg(i)             = numel (lst);
    idx(i, 1:numel (lst)) = lst;
  endfor
  [~, ord] = sortrows ([deg, idx]);
  T = T(ord, :);
endfunction

## ========================================================================== ##
## History
## ========================================================================== ##

function h = start_history (T, dev, ncoef, name, stat)
  h = struct ('Action', 'Start', 'TermName', name, 'Terms', T, ...
              'DF', ncoef, 'delDF', NaN, 'Deviance', dev, ...
              'Stat', stat, 'PValue', NaN);
endfunction

## Name of the Start row: the right-hand side of the starting model, which is
## '1' for a constant start and the whole term list for any other.
function nm = start_name (T, pred_names, p)
  s  = terms_formula (T, 'y', pred_names, p);
  nm = strtrim (s(index (s, '~') + 1:end));
endfunction

function h = mkhist (action, r, T, df, ddf, dev, stat, pval, p)
  ## pred_names not needed here: the term name is resolved by the caller
  h = struct ('Action', action, 'TermName', r, 'Terms', T, 'DF', df, ...
              'delDF', ddf, 'Deviance', dev, 'Stat', stat, 'PValue', pval);
endfunction

## Assemble the History table, naming the statistic column after the criterion.
function tbl = history_table (hist, crit, estdisp, pred_names, p)
  m        = numel (hist);
  Action   = cell (m, 1);
  TermName = cell (m, 1);
  Terms    = cell (m, 1);
  DF       = zeros (m, 1);
  delDF    = zeros (m, 1);
  Deviance = zeros (m, 1);
  Stat     = zeros (m, 1);
  PValue   = zeros (m, 1);
  for i = 1:m
    Action{i} = hist(i).Action;
    if (ischar (hist(i).TermName))
      TermName{i} = hist(i).TermName;
    else
      TermName{i} = term_name (hist(i).TermName, pred_names, p);
    endif
    ## The model's own term order, not the order the search added them in:
    ## MATLAB's last history row equals the fitted model's terms matrix.
    Terms{i}    = canonical_sort (hist(i).Terms, p);
    DF(i)       = hist(i).DF;
    delDF(i)    = hist(i).delDF;
    Deviance(i) = hist(i).Deviance;
    Stat(i)     = hist(i).Stat;
    PValue(i)   = hist(i).PValue;
  endfor
  ## Which columns the table carries is decided by the criterion: a p-value
  ## criterion reports the test that drove the step, an information criterion
  ## reports its own value after the step and has no test to report.  The
  ## deviance is reported only where it is the criterion.
  switch (crit)
    case 'deviance'
      statname = ternary (estdisp, 'FStat', 'Chi2Stat');
      tbl = table (Action, TermName, Terms, DF, delDF, Deviance, Stat, ...
                   PValue, 'VariableNames', {'Action', 'TermName', 'Terms', ...
                   'DF', 'delDF', 'Deviance', statname, 'PValue'});
    case 'sse'
      tbl = table (Action, TermName, Terms, DF, delDF, Stat, PValue, ...
                   'VariableNames', {'Action', 'TermName', 'Terms', 'DF', ...
                   'delDF', 'FStat', 'pValue'});
    otherwise
      tbl = table (Action, TermName, Terms, DF, delDF, Stat, ...
                   'VariableNames', {'Action', 'TermName', 'Terms', 'DF', ...
                   'delDF', upper(crit)});
  endswitch
endfunction

## Print an accepted step to the console (Verbose = 1).
function print_step (stepnum, h, crit, estdisp, pred_names, p)
  nm = term_name (h.TermName, pred_names, p);
  if (strcmp (h.Action, 'Add'))
    verb = 'Adding';
  else
    verb = 'Removing';
  endif
  switch (crit)
    case 'deviance'
      if (estdisp)
        printf ("%d. %s %s, Deviance = %g, FStat = %g, PValue = %g\n", ...
                stepnum, verb, nm, h.Deviance, h.Stat, h.PValue);
      else
        printf ("%d. %s %s, Deviance = %g, Chi2Stat = %g, PValue = %g\n", ...
                stepnum, verb, nm, h.Deviance, h.Stat, h.PValue);
      endif
    case 'sse'
      printf ("%d. %s %s, FStat = %g, pValue = %g\n", ...
              stepnum, verb, nm, h.Stat, h.PValue);
    case 'aic'
      printf ("%d. %s %s, AIC = %g\n", stepnum, verb, nm, h.Stat);
    case 'bic'
      printf ("%d. %s %s, BIC = %g\n", stepnum, verb, nm, h.Stat);
  endswitch
endfunction

## ========================================================================== ##
## Small helpers ported from GeneralizedLinearModel
## ========================================================================== ##

function s = criterion_label (crit, estdisp)
  switch (crit)
    case 'deviance'
      s = ternary (estdisp, 'deviance_f', 'deviance_chi2');
    otherwise
      s = crit;
  endswitch
endfunction

function spec = default_link (distr)
  switch (distr)
    case 'normal';            spec = 'identity';
    case 'binomial';          spec = 'logit';
    case 'poisson';           spec = 'log';
    case 'gamma';             spec = 'reciprocal';
    case 'inverse gaussian';  spec = -2;
  endswitch
endfunction

function ll = glm_loglik (distr, y, mu, N, w, phi)
  rmin = realmin;
  switch (distr)
    case 'normal'
      ne = sum (w);
      s2 = sum (w .* (y - mu) .^ 2) / ne;
      ll = -0.5 * ne * (log (2 * pi * s2) + 1);
    case 'poisson'
      ll = sum (w .* (y .* log (max (mu, rmin)) - mu - gammaln (y + 1)));
    case 'binomial'
      if (isempty (N))
        N = ones (size (y));
      endif
      yc = y .* N;
      ll = sum (w .* (gammaln (N + 1) - gammaln (yc + 1) ...
           - gammaln (N - yc + 1) + yc .* log (max (mu, rmin)) ...
           + (N - yc) .* log (max (1 - mu, rmin))));
    case 'gamma'
      a  = 1 ./ phi;
      ll = sum (w .* (a .* log (a) - a .* log (mu) + (a - 1) .* log (y) ...
           - a .* y ./ mu - gammaln (a)));
    case 'inverse gaussian'
      ll = sum (w .* (-0.5 * (log (2 * pi * phi .* y .^ 3) ...
           + (y - mu) .^ 2 ./ (phi .* mu .^ 2 .* y))));
  endswitch
endfunction

function out = ternary (cond, a, b)
  if (cond)
    out = a;
  else
    out = b;
  endif
endfunction

%!demo
%! ## Stepwise Poisson regression: start from a constant model and let the
%! ## search add the predictors that matter.
%! X = [0.83, -0.68; 0.22, 0.93; -0.12, 0.72; 0.55, -2.55; 1.89, 1.39; ...
%!      -1.46, -1.18; 1.06, -0.75; -0.89, 0.85; 0.19, -0.71; -0.43, -0.57];
%! y = [2; 2; 2; 1; 9; 0; 2; 1; 1; 1];
%! mdl = stepwiseglm (X, y, 'constant', 'Distribution', 'poisson', ...
%!                    'Upper', 'linear')

%!demo
%! ## Stepwise logistic regression selected by AIC, reported from a table.
%! X = [0.83, 1.02; 0.22, 0.29; -0.12, 0.09; 0.55, 0.56; 1.89, 1.96; ...
%!      -1.46, -0.46; 1.06, -0.87; -0.89, 1.18; 0.19, -1.00; -0.43, -0.04];
%! y = [1; 1; 0; 1; 1; 0; 1; 0; 1; 0];
%! tbl = array2table ([X, y], 'VariableNames', {'x1', 'x2', 'y'});
%! mdl = stepwiseglm (tbl, 'constant', 'Distribution', 'binomial', ...
%!                    'Criterion', 'aic', 'Verbose', 0)

## Shared test data (identical to the MATLAB stepwiseglm verification probes).
## CT and CU carry a three-level categorical predictor; CU adds a second one,
## H, that the response does not depend on, so that it can be dropped.
%!shared X, yb, yp, yn, CT, CU, CN
%! X = [ 0.83, -0.68,  1.02,  0.02;  0.22,  0.93,  0.29, -0.57; ...
%!      -0.12,  0.72,  0.09, -1.03;  0.55, -2.55,  0.56,  0.97; ...
%!       1.89,  1.39,  1.96,  0.16; -1.46, -1.18, -0.46,  0.33; ...
%!       1.06, -0.75, -0.87,  0.08; -0.89,  0.85,  1.18, -1.04; ...
%!       0.19, -0.71, -1.00,  1.85; -0.43, -0.57, -0.04,  0.75; ...
%!      -0.90, -1.75, -0.61, -0.68;  1.52, -0.10,  0.43, -1.16; ...
%!       0.58, -1.63,  0.08, -0.73;  0.11,  0.87, -0.40, -0.15; ...
%!       1.26, -0.42, -0.95, -1.07; -0.02,  1.09,  1.03,  1.54; ...
%!       0.80,  0.97,  0.53,  0.62; -0.40, -1.18,  2.83, -1.36; ...
%!      -0.61, -0.44, -1.85, -1.57;  1.22, -0.94, -0.26, -0.75; ...
%!      -0.84,  0.11, -0.55, -0.42;  1.66, -1.27, -1.05, -0.98; ...
%!       0.29,  0.59, -0.36, -0.74; -1.10, -1.29,  0.40,  0.35; ...
%!       0.08, -0.90, -0.74, -0.51;  1.48, -0.04, -0.11, -1.47; ...
%!       0.06,  0.05, -0.09, -1.55;  0.84,  0.13, -1.11, -1.24; ...
%!      -0.72, -0.02,  0.75, -0.39;  1.11,  0.34, -1.32, -0.18; ...
%!       1.48, -1.55, -0.06,  1.28; -0.16, -0.24,  1.34, -1.92; ...
%!      -0.67,  0.80,  1.01, -0.07; -0.26, -1.10, -0.33, -0.88; ...
%!      -0.26, -2.42,  0.77, -1.44; -0.62, -0.38, -0.38, -1.77; ...
%!       0.41,  0.90,  0.85,  0.72; -1.16,  0.99,  0.74, -2.00; ...
%!      -0.44, -1.22,  1.06, -1.14;  1.85,  0.21,  0.65, -0.11];
%! yb = [1 1 0 1 1 0 1 0 1 0 0 1 0 1 1 1 1 1 0 1 0 0 0 0 0 1 0 1 0 1 ...
%!       0 1 0 0 1 0 1 0 0 1]';
%! yp = [2 2 2 1 9 0 2 1 1 1 0 3 1 2 2 2 3 1 1 2 1 2 2 0 1 3 1 2 1 3 ...
%!       2 1 1 1 0 1 3 1 0 5]';
%! yn = [3.17 1.66 0.99 3.87 6.22 1.34 4.8 0.58 4.05 2.06 2.81 3.97 3.84 ...
%!       1.11 4.26 2.57 3.07 -1.07 1.7 4.58 0.47 6.41 1.55 1.02 3.29 3.81 ...
%!       1.58 2.65 0.81 2.94 5.93 1.09 1.06 2.55 1.44 0.97 2.8 -0.47 0.81 ...
%!       4.7]';
%! lev = {'A', 'B', 'C'}';
%! a1 = ((1:48)' - 24.5)/12;
%! a2 = sin ((1:48)'/5);
%! ag = lev(mod ((0:47)', 3) + 1);
%! ay = [0 1 0 1 2 0 1 2 0 1 2 1 1 2 1 1 3 1 1 3 1 1 3 1 1 4 1 2 5 1 3 8 ...
%!       2 4 11 3 5 15 4 7 18 4 8 20 4 8 20 5]';
%! CT = table (a1, a2, ag, ay, 'VariableNames', {'x1', 'x2', 'g', 'y'});
%! b1 = ((1:60)' - 30.5)/15;
%! bg = lev(mod ((0:59)', 3) + 1);
%! bh = lev(mod (floor ((0:59)'/2), 3) + 1);
%! by = [2 0 7 2 0 6 2 0 6 2 1 5 2 1 5 2 1 5 2 1 4 3 2 4 3 2 4 3 3 3 3 4 ...
%!       3 4 5 3 4 6 3 4 8 2 5 11 2 5 14 2 5 18 2 6 23 2 6 30 2 7 39 2]';
%! CU = table (b1, bg, bh, by, 'VariableNames', {'x1', 'g', 'h', 'y'});
%! CN = table (b1, bg, bh, by / 4, 'VariableNames', {'x1', 'g', 'h', 'y'});

## Test results (values verified against MATLAB's stepwiseglm)
%!test
%! ## Binomial: the Deviance criterion selects x1 and x3.
%! mdl = stepwiseglm (X, yb, 'constant', 'Distribution', 'binomial', ...
%!                    'Upper', 'interactions', 'Verbose', 0);
%! assert_equal (mdl.CoefficientNames, {'(Intercept)', 'x1', 'x3'});
%! assert_equal (mdl.Coefficients.Estimate, ...
%!   [-0.567847008659897; 2.24005553968806; 1.10257647012], 1e-8);
%! assert_equal (mdl.Deviance, 35.424118776089266, 1e-9);
%! assert_equal (mdl.LogLikelihood, -17.712059388044633, 1e-9);

%!test
%! ## Poisson: selects x1 and x2 (interaction not significant).
%! mdl = stepwiseglm (X, yp, 'constant', 'Distribution', 'poisson', ...
%!                    'Verbose', 0);
%! assert_equal (mdl.CoefficientNames, {'(Intercept)', 'x1', 'x2'});
%! assert_equal (mdl.Coefficients.Estimate, ...
%!   [0.242293607853268; 0.672082935896644; 0.459103523205052], 1e-8);
%! assert_equal (mdl.Deviance, 5.62441316952502, 1e-9);

%!test
%! ## Normal (estimated dispersion, F-test): mains plus x1:x4 and x2:x3.
%! mdl = stepwiseglm (X, yn, 'constant', 'Verbose', 0);
%! assert_equal (mdl.CoefficientNames, ...
%!   {'(Intercept)', 'x1', 'x2', 'x3', 'x4', 'x1:x4', 'x2:x3'});
%! assert_equal (mdl.NumCoefficients, 7);
%! assert_equal (any (strcmp (mdl.CoefficientNames, 'x2:x3')), true);

%!test
%! ## The AIC criterion is greedier and also brings in x3:x4.
%! mdl = stepwiseglm (X, yn, 'linear', 'Upper', 'interactions', ...
%!                    'Criterion', 'aic', 'Verbose', 0);
%! assert_equal (mdl.NumCoefficients, 8);
%! assert_equal (any (strcmp (mdl.CoefficientNames, 'x3:x4')), true);

%!test
%! ## BIC reaches the same eight-coefficient model here.
%! mdl = stepwiseglm (X, yn, 'constant', 'Criterion', 'bic', 'Verbose', 0);
%! assert_equal (mdl.NumCoefficients, 8);
%! assert_equal (any (strcmp (mdl.CoefficientNames, 'x3:x4')), true);

%!test
%! ## A formula-valued Upper bounds the candidate universe.
%! mdl = stepwiseglm (X, yp, 'y ~ x1', 'Distribution', 'poisson', ...
%!                    'Upper', 'y ~ x1 + x2 + x3', 'Verbose', 0);
%! assert_equal (mdl.CoefficientNames, {'(Intercept)', 'x1', 'x2'});

%!test
%! ## Lower/Upper keywords: search within [linear, quadratic] adds interactions.
%! mdl = stepwiseglm (X, yn, 'linear', 'Lower', 'linear', ...
%!                    'Upper', 'quadratic', 'Verbose', 0);
%! assert_equal (mdl.NumCoefficients, 7);
%! assert_equal (any (strcmp (mdl.CoefficientNames, 'x2:x3')), true);
%! assert_equal (any (strcmp (mdl.CoefficientNames, 'x1:x4')), true);

%!test
%! ## The Steps property records the trace; History matches the deviance test.
%! mdl = stepwiseglm (X, yb, 'constant', 'Distribution', 'binomial', ...
%!                    'Upper', 'interactions', 'Verbose', 0);
%! assert_equal (mdl.Steps.Criterion, 'deviance_chi2');
%! assert_equal (size (mdl.Steps.History, 1), 3);
%! assert_equal (mdl.Steps.History.Action{1}, 'Start');
%! assert_equal (mdl.Steps.History.Chi2Stat(2), 14.8398950335268, 1e-6);

%!test
%! ## Table input, response taken from the last column by default.
%! tbl = array2table ([X(:,1:2), yp], 'VariableNames', {'a', 'b', 'y'});
%! mdl = stepwiseglm (tbl, 'constant', 'Distribution', 'poisson', ...
%!                    'Verbose', 0);
%! assert_equal (mdl.CoefficientNames, {'(Intercept)', 'a', 'b'});

%!test
%! ## Collinear predictors are fitted, not refused; the redundant column is
%! ## dropped rather than entering the model.
%! mdl = stepwiseglm ([X(:,1), X(:,1)], yn, 'constant', 'Verbose', 0);
%! assert_equal (class (mdl), 'GeneralizedLinearModel');
%! assert_equal (mdl.CoefficientNames, {'(Intercept)', 'x1'});

%!test
%! ## A cell-array column of a table is taken as categorical without being
%! ## named, and the fit matches MATLAB R2024a.
%! mdl = stepwiseglm (CT, 'y ~ 1', 'Upper', 'y ~ x1 + x2 + g', ...
%!                    'Distribution', 'poisson', 'Verbose', 0);
%! assert_equal (mdl.CoefficientNames, ...
%!               {'(Intercept)', 'x1', 'x2', 'g_B', 'g_C'});
%! assert_equal (mdl.Coefficients.Estimate, ...
%!               [0.603329330791641; 0.796711055930047; 0.285851899335031; ...
%!                0.915480956228751; -0.550531653341791], 1e-10);
%! assert_equal (mdl.Deviance, 5.49511881200541, 1e-10);

%!test
%! ## The indicators of a categorical predictor enter as one term, so the
%! ## step is worth L-1 degrees of freedom and is named for the predictor.
%! mdl = stepwiseglm (CT, 'y ~ 1', 'Upper', 'y ~ x1 + x2 + g', ...
%!                    'Distribution', 'poisson', 'Verbose', 0);
%! assert_equal (mdl.Steps.History.TermName, {'1'; 'x1'; 'g'; 'x2'});
%! assert_equal (mdl.Steps.History.DF, [1; 2; 4; 5]);
%! assert_equal (mdl.Steps.History.delDF, [NaN; 1; 2; 1]);

%!test
%! ## The deviance test of a grouped term is the joint test of its indicators.
%! mdl = stepwiseglm (CT, 'y ~ 1', 'Upper', 'y ~ x1 + x2 + g', ...
%!                    'Distribution', 'poisson', 'Verbose', 0);
%! assert_equal (mdl.Steps.History.Deviance, ...
%!               [220.977093395079; 81.2408117834874; 10.3794318146782; ...
%!                5.49511881200541], 1e-9);
%! assert_equal (mdl.Steps.History.Chi2Stat, ...
%!               [NaN; 139.736281611591; 70.8613799688091; ...
%!                4.88431300267280], 1e-9);
%! assert_equal (mdl.Steps.History.PValue(4), 0.0271018179857880, 1e-12);

%!test
%! ## An interaction naming a categorical predictor is also one term, and its
%! ## coefficients are named for the indicators.
%! mdl = stepwiseglm (CU, 'y ~ 1', 'Upper', 'y ~ x1*g', ...
%!                    'Distribution', 'poisson', 'Verbose', 0);
%! assert_equal (mdl.CoefficientNames, {'(Intercept)', 'x1', 'g_B', 'g_C', ...
%!                                      'x1:g_B', 'x1:g_C'});
%! assert_equal (mdl.Steps.History.TermName, {'1'; 'x1'; 'g'; 'x1:g'});
%! assert_equal (mdl.Steps.History.delDF, [NaN; 1; 2; 2]);
%! assert_equal (mdl.Coefficients.Estimate, ...
%!               [1.21521326020005; 0.372340711075598; -0.0523666888775920; ...
%!                0; 0.950243386703989; -0.744681422151197], 1e-9);

%!test
%! ## A grouped term leaves in one step too, and its delDF is negative.
%! mdl = stepwiseglm (CU, 'y ~ x1 + g + h', 'Lower', 'y ~ 1', 'Upper', ...
%!                    'y ~ x1 + g + h', 'Distribution', 'poisson', ...
%!                    'Verbose', 0);
%! assert_equal (mdl.Formula.LinearPredictor, '1 + x1 + g');
%! assert_equal (mdl.Steps.History.Action, {'Start'; 'Remove'});
%! assert_equal (mdl.Steps.History.TermName, {'1 + x1 + g + h'; 'h'});
%! assert_equal (mdl.Steps.History.DF, [6; 4]);
%! assert_equal (mdl.Steps.History.delDF, [NaN; -2]);
%! assert_equal (mdl.Steps.History.Chi2Stat, [NaN; 0.914420851291737], 1e-10);

%!test
%! ## A categorical array is coded by its categories, a cell array by the
%! ## order its labels appear; on this fixture the two agree.
%! ct = CT;
%! ct.g = categorical (ct.g);
%! m1 = stepwiseglm (ct, 'y ~ 1', 'Upper', 'y ~ x1 + x2 + g', ...
%!                   'Distribution', 'poisson', 'Verbose', 0);
%! m2 = stepwiseglm (CT, 'y ~ 1', 'Upper', 'y ~ x1 + x2 + g', ...
%!                   'Distribution', 'poisson', 'Verbose', 0);
%! assert_equal (m1.CoefficientNames, m2.CoefficientNames);
%! assert_equal (m1.Coefficients.Estimate, m2.Coefficients.Estimate, 1e-12);

%!test
%! ## A column of a predictor matrix is categorical only when named, and the
%! ## levels are then its sorted distinct values.
%! Xm = [((1:60)' - 30.5)/15, mod((0:59)', 3) + 1, ...
%!       mod(floor((0:59)'/2), 3) + 1];
%! ym = CU.y;
%! mdl = stepwiseglm (Xm, ym, 'constant', 'Upper', 'linear', ...
%!                    'Distribution', 'poisson', ...
%!                    'CategoricalVars', logical ([0, 1, 1]), 'Verbose', 0);
%! assert_equal (mdl.CoefficientNames, {'(Intercept)', 'x1', 'x2_2', 'x2_3'});
%! assert_equal (mdl.Steps.History.TermName, {'1'; 'x1'; 'x2'});
%! assert_equal (mdl.Steps.History.delDF, [NaN; 1; 2]);

%!test
%! ## Indices and a logical vector mark the same columns.
%! Xm = [((1:60)' - 30.5)/15, mod((0:59)', 3) + 1, ...
%!       mod(floor((0:59)'/2), 3) + 1];
%! ym = CU.y;
%! m1 = stepwiseglm (Xm, ym, 'constant', 'Upper', 'linear', ...
%!                   'Distribution', 'poisson', 'CategoricalVars', [2, 3], ...
%!                   'Verbose', 0);
%! m2 = stepwiseglm (Xm, ym, 'constant', 'Upper', 'linear', ...
%!                   'Distribution', 'poisson', ...
%!                   'CategoricalVars', logical ([0, 1, 1]), 'Verbose', 0);
%! assert_equal (m1.Coefficients.Estimate, m2.Coefficients.Estimate, 1e-12);

%!test
%! ## The Start row names the starting model, not the constant, and reports
%! ## the coefficient count it was fitted with.
%! mdl = stepwiseglm (CU, 'y ~ x1 + g', 'Lower', 'y ~ x1 + g', 'Upper', ...
%!                    'y ~ x1 + g', 'Distribution', 'poisson', 'Verbose', 0);
%! assert_equal (mdl.Steps.History.TermName, {'1 + x1 + g'});
%! assert_equal (mdl.Steps.History.DF, 4);

%!test
%! ## An estimated dispersion turns the deviance test into an F test, and the
%! ## criterion is labelled for it.
%! mdl = stepwiseglm (CN, 'y ~ 1', 'Upper', 'y ~ x1 + g + h', ...
%!                    'Distribution', 'normal', 'Verbose', 0);
%! assert_equal (mdl.Steps.Criterion, 'deviance_f');
%! assert_equal (mdl.Steps.History.Properties.VariableNames, ...
%!               {'Action', 'TermName', 'Terms', 'DF', 'delDF', 'Deviance', ...
%!                'FStat', 'PValue'});
%! assert_equal (mdl.Steps.History.FStat, ...
%!               [NaN; 15.1516937210352; 4.75429941449063], 1e-10);

%!test
%! ## Under 'sse' the history reports the F test alone, with MATLAB's own
%! ## lower-case column name.
%! mdl = stepwiseglm (CN, 'y ~ 1', 'Upper', 'y ~ x1 + g + h', ...
%!                    'Distribution', 'normal', 'Criterion', 'sse', ...
%!                    'Verbose', 0);
%! assert_equal (mdl.Steps.History.Properties.VariableNames, ...
%!               {'Action', 'TermName', 'Terms', 'DF', 'delDF', 'FStat', ...
%!                'pValue'});
%! assert_equal (mdl.Steps.History.pValue, ...
%!               [NaN; 0.000258691112717; 0.012385457865093], 1e-12);

%!test
%! ## An information criterion has no test to report, so the history carries
%! ## its value instead, the starting model included.
%! mdl = stepwiseglm (CU, 'y ~ 1', 'Upper', 'y ~ x1 + g + h', ...
%!                    'Distribution', 'poisson', 'Criterion', 'aic', ...
%!                    'Verbose', 0);
%! assert_equal (mdl.Steps.History.Properties.VariableNames, ...
%!               {'Action', 'TermName', 'Terms', 'DF', 'delDF', 'AIC'});
%! assert_equal (mdl.Steps.History.AIC, ...
%!               [504.960173118363; 394.245450753972; 341.770323510125], 1e-9);

%!test
%! ## A p-value far below eps is computed on the upper tail, not as one minus
%! ## the lower, so it does not round to zero.
%! mdl = stepwiseglm (CU, 'y ~ 1', 'Upper', 'y ~ x1*g', ...
%!                    'Distribution', 'poisson', 'Verbose', 0);
%! assert_equal (mdl.Steps.History.PValue(2), 2.49165e-26, 1e-30);
%! assert_equal (mdl.Steps.History.PValue(4), 1.19350e-32, 1e-36);

%!test
%! ## Each history row carries the terms in the model's own order, not in the
%! ## order the search added them, so the last row is the fitted model's.
%! mdl = stepwiseglm (CT, 'y ~ 1', 'Upper', 'y ~ x1 + x2 + g', ...
%!                    'Distribution', 'poisson', 'Verbose', 0);
%! assert_equal (mdl.Steps.History.TermName, {'1'; 'x1'; 'g'; 'x2'});
%! assert_equal (mdl.Steps.History.Terms{end}, mdl.Formula.Terms);
%! assert_equal (mdl.Steps.History.Terms{end}, ...
%!               [0, 0, 0, 0; 1, 0, 0, 0; 0, 1, 0, 0; 0, 0, 1, 0]);

%!test
%! ## Start, Lower, and Upper are formula objects carrying the link.
%! mdl = stepwiseglm (CT, 'y ~ 1', 'Upper', 'y ~ x1 + x2 + g', ...
%!                    'Distribution', 'poisson', 'Verbose', 0);
%! assert_equal (class (mdl.Steps.Start), 'LinearFormula');
%! assert_equal (char (mdl.Steps.Start), 'log(y) ~ 1');
%! assert_equal (char (mdl.Steps.Upper), 'log(y) ~ 1 + x1 + x2 + g');
%! assert_equal (mdl.Steps.Lower.LinearPredictor, '1');

## Test input validation
%!error<Invalid call> stepwiseglm ()
%!error<stepwiseglm: X must be a real matrix.> stepwiseglm ("a", [1;2])
%!error<stepwiseglm: unknown distribution 'wibble'.> ...
%! stepwiseglm ([1, 2; 3, 4], [1; 0], 'Distribution', 'wibble')
%!error<stepwiseglm: unknown criterion 'nope'.> ...
%! stepwiseglm ([1, 2; 3, 4], [1; 0], 'Criterion', 'nope')
%!error<stepwiseglm: unknown parameter name 'foo'.> ...
%! stepwiseglm ([1, 2; 3, 4], [1; 0], 'linear', 'foo', 1)
%!error<stepwiseglm: unknown categorical predictor 'nope'.> ...
%! stepwiseglm ([1, 2; 3, 4], [1; 0], 'CategoricalVars', {'nope'})
%!error<stepwiseglm: 'CategoricalVars' must be predictor names, indices, or a logical vector.> ...
%! stepwiseglm ([1, 2; 3, 4], [1; 0], 'CategoricalVars', {1})

%!test  # a two-column binomial response matches MATLAB R2024a
%! x = (1:10)';
%! S = [0 1 1 2 3 4 6 7 9 9]';
%! mdl = stepwiseglm (x, [S, 10 * ones(10, 1)], 'constant', 'upper', ...
%!                    'linear', 'Distribution', 'binomial', 'Verbose', 0);
%! assert_equal (mdl.Coefficients.Estimate, ...
%!               [-4.07619632416318; 0.639874139429377], 1e-10);
%! assert_equal (mdl.Deviance, 1.37328920133713, 1e-10);

%!test  # the two-column and BinomialSize forms select the same model
%! x = (1:10)';
%! S = [0 1 1 2 3 4 6 7 9 9]';
%! N = 10 * ones (10, 1);
%! m1 = stepwiseglm (x, [S, N], 'constant', 'upper', 'linear', ...
%!                   'Distribution', 'binomial', 'Verbose', 0);
%! m2 = stepwiseglm (x, S, 'constant', 'upper', 'linear', ...
%!                   'Distribution', 'binomial', 'BinomialSize', N, ...
%!                   'Verbose', 0);
%! assert_equal (m2.Coefficients.Estimate, m1.Coefficients.Estimate, 1e-12);
