## Copyright (C) 2024-2026 Andreas Bertsatos <abertsatos@biol.uoa.gr>
##
## This file is part of the datatypes 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/>.

classdef categorical
  ## -*- texinfo -*-
  ## @deftp {datatypes} categorical
  ##
  ## Array representing categorical data.
  ##
  ## A @code{categorical} array represents an array of values that correspond
  ## to a finite set of discrete categories, which can be either ordinal (having
  ## a mathematical ordering) or nominal.  It is an efficient way to define
  ## groups of rows in a table or to other types of variables.
  ##
  ## Each @code{categorical} array stores the list of categories as a cell array
  ## of character vectors and a numeric array of @qcode{uint16} type as indices
  ## to the categories.  The categorical array may also store elements of
  ## undefined categorical values, which represent the absence of a given
  ## category and correspond to the @qcode{NaN} value for numeric arrays or in
  ## general to the missing value for other data types.
  ##
  ## @code{categorical} arrays do not have any public properties, which can be
  ## indexed by using dot notation similarly to structures.  However, there are
  ## several methods which can be used to modify their categories once they are
  ## constructed.
  ##
  ## @end deftp

  properties (SetAccess = private, Hidden)
    ## Category Names
    cats = {}
    ## Indices to categories
    code = uint16 ([])
    ## <undefined> elements
    isMissing = logical ([])
    ## Flag for ordinal categories
    isOrdinal = false
    ## Flag for protecting category list
    isProtected = false
  endproperties

  methods (Hidden)

    ## Custom display
    function display (this)
      in_name = inputname (1);
      if (! isempty (in_name))
        fprintf ('%s =\n', in_name);
      endif
      __disp__ (this, 'categorical', in_name);
    endfunction

    ## Custom display
    function disp (this)
      __disp__ (this, 'categorical');
    endfunction

  endmethods

  methods (Static, Access = public)

    ## -*- texinfo -*-
    ## @deftypefn  {categorical} {@var{E} =} categorical.empty ()
    ## @deftypefnx {categorical} {@var{E} =} categorical.empty (@var{sz})
    ## @deftypefnx {categorical} {@var{E} =} categorical.empty (@var{m}, @var{n}, @dots{})
    ##
    ## Create an empty categorical array.
    ##
    ## @code{@var{E} = categorical.empty ()} returns a @math{0*0} empty
    ## categorical array with no categories.  @code{categorical.empty (@var{m},
    ## @var{n}, @dots{})} or @code{categorical.empty (@var{sz})} returns an
    ## empty categorical array of the requested size, which must have at least
    ## one dimension equal to zero.  A lone dimension gives a square size, so
    ## @code{categorical.empty (3)} is an error while
    ## @code{categorical.empty (0)} is @math{0*0}.  As for @code{zeros}, a
    ## negative dimension counts as zero, and a size vector with nothing in it
    ## names no size and gives @math{0*0}.
    ##
    ## @end deftypefn
    function E = empty (varargin)
      if (nargin == 0)
        sz = [0, 0];
      elseif (nargin == 1 && ! isscalar (varargin{1}))
        sz = double (varargin{1}(:)).';
      else
        sz = [varargin{:}];
      endif
      if (! (isnumeric (sz) && isrow (sz) && all (sz == fix (sz))))
        error ("categorical.empty: dimensions must be integer values.");
      endif
      ## A negative dimension is no smaller than none at all, and a size vector
      ## holding no dimensions names no size: 'zeros' and every other array
      ## constructor read both the same way.
      sz = max (sz, 0);
      if (isempty (sz))
        sz = [0, 0];
      elseif (isscalar (sz))
        sz = [sz, sz];
      endif
      if (all (sz != 0))
        error (strcat ("categorical.empty: at least one dimension must be", ...
                       " zero for an empty array."));
      endif
      E = categorical (nan (sz));
    endfunction

  endmethods

################################################################################
##                ** Create and convert 'categorical' type **                 ##
################################################################################
##                             Available Methods                              ##
##                                                                            ##
## 'categorical'      'dispstrings'      'cellstr'          'char'            ##
## 'double'           'single'           'int64'            'int32'           ##
## 'int16'            'int8'             'uint64'           'uint32'          ##
## 'uint16'           'uint8'                                                 ##
##                                                                            ##
################################################################################

  methods (Access = public)

    ## -*- texinfo -*-
    ## @deftypefn  {categorical} {@var{C} =} categorical (@var{A})
    ## @deftypefnx {categorical} {@var{C} =} categorical (@var{A}, @var{valueset})
    ## @deftypefnx {categorical} {@var{C} =} categorical (@var{A}, @var{valueset}, @var{catnames})
    ## @deftypefnx {categorical} {@var{C} =} categorical (@dots{}, @var{Name}, @var{Value})
    ##
    ## Create a new array of categorical values.
    ##
    ## @code{@var{C} = categorical (@var{A})} creates a categorical array
    ## @var{C} from the input array @var{A}, which can be numeric, logical,
    ## datetime, duration, string, or cell array of character vectors.  Input
    ## @var{A} can also be another categorical array.  The categories in @var{C}
    ## the sorted unique values from the input array @var{A}.  When the input
    ## array is string or cell array of character vectors, any leading or
    ## trailing white spaces are removed.  Missing values in the input array
    ## correspond to @qcode{<undefined>} elements in the created categorical
    ## array.  By default, there is no category for undefined values in the
    ## output array.
    ##
    ## @code{@var{C} = categorical (@var{A}, @var{valueset})} creates a
    ## categorical array from input @var{A} with the categories specified in
    ## @var{valueset}, which must be a vector of unique values.  The data type
    ## of input array @var{A} and @var{valueset} must be the same, unless they
    ## are string or cell arrays of character vectors, in which case they can be
    ## used interchangeably.  Similarly to input array @var{A} any leading or
    ## trailing white spaces are removed, if @var{valueset} is a string or cell
    ## array of character vectors.
    ##
    ## @code{@var{C} = categorical (@var{A}, @var{valueset}, @var{catnames})}
    ## creates a categorical array from input @var{A} with the categories
    ## specified in @var{valueset} and named after the corresponding values in
    ## @var{catnames}, which must be specified either as a string array or a
    ## cell array of character vectors.  If omitted, @code{categorical} uses
    ## the cellstring representation of @var{valueset} to name the specified
    ## category names.  @var{catnames} must not contain any missing values, it
    ## may have duplicate names, and it must have the same number of elements as
    ## @var{valueset}.
    ##
    ## @code{@var{C} = categorical (@dots{}, @var{Name}, @var{Value})} further
    ## specifies additional parameters for creating categorical array @var{C}.
    ##
    ## @itemize
    ## @item @qcode{"Ordinal"} must be a logical scalar specifying that the
    ## categories in @var{C} have a numeric ordering relationship.  By default,
    ## it is @qcode{false} and @code{categorical} creates a non-ordinal array.
    ## The elements of unordered categorical arrays can only be compared for
    ## equality.  Any other relational operator cannot be used.  Setting
    ## @qcode{"Ordinal"} to @qcode{true} results in a categorical array with
    ## mathematically ordered categories.  The ordering goes from smallest to
    ## largest according to the order in @var{valueset} or the order of
    ## appearance in input array @var{A}, when @var{valueset} is not specified,
    ## in which case the unique values in @var{A} are not sorted in order to set
    ## the categories.  Ordinal categorical arrays allow for relational
    ## operators such as @qcode{>=, >, <=, <}, as well as statistical operations
    ## such as @code{min}, @code{max}, and @code{median}.
    ##
    ## @item @qcode{"Protected"} must be a logical scalar specifying that the
    ## categories in @var{C} are protected.  By default, it is @qcode{false} for
    ## unordered categorical arrays and it is always @qcode{true} for ordinal
    ## categorical arrays.  Setting @qcode{"Protected"} to @qcode{true} prevents
    ## from assigning new values that do not correspond to existing categories.
    ## When @qcode{false}, assigning new values to the array automatically
    ## updates the categories. Hence, categorical arrays with different sets of
    ## categories can be combined/merged into a new array with set operations.
    ## @end itemize
    ##
    ## @seealso{categories, discretize, iscategorical}
    ## @end deftypefn
    function this = categorical (x, varargin)

      ## Return an empty categorical object
      if (nargin == 0)
        return;
      endif

      ## Parse optional Name-Value paired arguments
      optNames = {'Ordinal', 'Protected'};
      dfValues = {false, false};
      [Ordinal, Protected, args] = parsePairedArguments (optNames, dfValues, ...
                                                         varargin(:));

      ## Check optional Name-Value paired arguments
      if (! ismember (Ordinal, [0, 1]))
        error (strcat ("categorical: 'Ordinal' variable indicator", ...
                       " must be either false (0) or true (1)."));
      endif
      if (! ismember (Protected, [0, 1]))
        error (strcat ("categorical: 'Protected' categories indicator", ...
                       " must be either false (0) or true (1)."));
      endif
      if (Ordinal)
        this.isOrdinal = true;
        this.isProtected = true;
      elseif (Protected)
        this.isProtected = true;
      endif

      ## Function handle for identifying missing text
      fmt = @(x) isempty (strtrim (x));

      ## Handle categorical input first
      if (isa (x, 'categorical'))
        this.cats = x.cats;
        this.code = x.code;
        this.isMissing = x.isMissing;
        ## Set new categories if valueset argument is provided
        if (numel (args) > 0)
          ## valueset must be a same type as data input (categorical)
          catvset = args{1};
          if (isa (catvset, 'categorical'))
            isnanvset = catvset.isMissing;
            codevset = catvset.code;
            if (sum (isnanvset) > 1)
              error ("categorical: VALUESET contains multiple missing values.");
            elseif (numel (unique (codevset)) != numel (codevset))
              error ("categorical: VALUESET contains non-unique values.");
            endif
            if (any (isnanvset))
              if (numel (args) == 1)
                error (strcat ("categorical: VALUESET with missing value", ...
                               " requires CATNAMES."));
              endif
              ## Keep index of missing values in x
              isnanx = this.isMissing;
              ## Get CATNAMES
              catnames = args{2};
              if (! (iscellstr (catnames) || isa (catnames, 'string')))
                error ("categorical: invalid type of CATNAMES.");
              endif
              catnames = cellstr (catnames);
              ## codevset must include 0 since there is an undefined element
              if (numel (codevset) != numel (catnames))
                error (strcat ("categorical: CATNAMES and VALUESET", ...
                               " lengths do not match."));
              endif
              if (any (cellfun (fmt, catnames)))
                error (strcat ("categorical: CATNAMES contain empty or", ...
                               " missing strings."));
              endif
              ## Separate missing from nonmissing catnames
              valueset = catvset.cats(codevset(! isnanvset));
              ## Set nonmissing values
              this = setcats (this, valueset);
              ## Find code value for missing catname, add 1 to all code values
              ## equal to or greater than the missing code value, and convert
              ## all initial 0 code values to the new missing catname value.
              nanvalue = find (isnanvset);
              this.code(this.code >= nanvalue) += 1;
              this.code(isnanx) = uint16 (nanvalue);
              this.isMissing(isnanx) = false;
              this.cats = catnames(:);
            else
              valueset = catvset.cats(codevset);
              this = setcats (this, valueset);
              ## Set new category names if catname argument is provided
              if (numel (args) > 1)
                catnames = args{2};
                if (! (iscellstr (catnames) || isa (catnames, 'string')))
                  error ("categorical: invalid type of CATNAMES.");
                endif
                catnames = cellstr (catnames);
                if (numel (valueset) != numel (catnames))
                  error (strcat ("categorical: CATNAMES and VALUESET", ...
                                 " lengths do not match."));
                endif
                if (any (cellfun (fmt, catnames)))
                  error (strcat ("categorical: CATNAMES contain empty or", ...
                                 " missing strings."));
                endif
                this.cats = catnames(:);
              endif
            endif
            ## In case of duplicate catnames, regrouping is required
            if (numel (args) > 1 && ...
                numel (unique (catnames)) != numel (catnames))
              ## Generate regrouping index to unique categories
              index2cat = __grp2idx__ (catnames);
              idx = ! this.isMissing;
              this.code(idx) = arrayfun (@(x) index2cat(x), this.code(idx));
              this.cats = unique (this.cats, 'stable');
            endif
          else
            error ("categorical: incompatible types of VALUESET and X.");
          endif
        endif
        return;
      endif

      ## Handle input arguments
      index2cat = [];
      isnanvset = false;
      ## Input Array (x)
      if (iscellstr (x))
        classx = 'cellstr';
        isnanx = cellfun (fmt, x);
      elseif (isnumeric (x))
        classx = 'numeric';
        isnanx = isnan (x);
      elseif (isa (x, 'datetime'))
        classx = 'datetime';
        isnanx = isnat (x);
      elseif (isa (x, 'duration'))
        classx = 'duration';
        isnanx = isnan (x);
      elseif (islogical (x))
        classx = 'logical';
        isnanx = isnan (x);
      elseif (isa (x, 'string'))
        classx = 'string';
        isnanx = cellfun (fmt, cellstr (x));
      else
        error ("categorical: invalid input type for X.");
      endif
      ## Force isnanx to logical array in case of empty text input
      isnanx = logical (isnanx);
      ## Categories (valueset)
      if (numel (args) > 0)
        valueset = args{1};
        if (iscellstr (valueset))
          classv = 'cellstr';
        elseif (isnumeric (valueset))
          classv = 'numeric';
        else
          classv = class (valueset);
        endif
        if (any (strcmp (classx, {'cellstr', 'string'})))
          if (! (iscellstr (valueset) || isa (valueset, 'string')))
            error ("categorical: incompatible types of VALUESET and X.");
          endif
          if (iscellstr (valueset))
            isnanvset = cellfun (fmt, valueset);
          else  # string
            isnanvset = cellfun (fmt, cellstr (valueset));
          endif
        elseif (isequal (classx, classv))
          if (strcmp (classv, 'datetime'))
            isnanvset = isnat (valueset);
          else  # numeric, duration,
            isnanvset = isnan (valueset);
          endif
        else
          error ("categorical: incompatible types of VALUESET and X.");
        endif
        if (any (isnanvset) && numel (args) == 1)
          error ("categorical: VALUESET with missing value requires CATNAMES.");
        endif
        if (sum (isnanvset) > 1)
          error ("categorical: VALUESET contains multiple missing values.");
        endif
        if (prod (size (unique (valueset))) < prod (size (valueset)))
          error ("categorical: VALUESET contains non-unique values.");
        endif
        ## Category names (catnames)
        if (numel (args) > 1)
          catnames = args{2};
          if (! (iscellstr (catnames) || isa (catnames, 'string')))
            error ("categorical: invalid type of CATNAMES.");
          endif
          catnames = cellstr (catnames);
          if (prod (size (valueset)) != numel (catnames))
            error ("categorical: CATNAMES and VALUESET lengths do not match.");
          endif
          if (any (cellfun (@(x) isempty (strtrim (x)), catnames)))
            error ("categorical: CATNAMES contain empty or missing strings.");
          endif
          ## Create index vector to categories
          index2cat = __grp2idx__ (catnames);
        else
          ## Check valueset for missing or empty elements
          if (any (strcmp (classv, {'cellstr', 'string'})))
            valueset = cellstr (valueset);
            catnames = valueset;
          elseif (isnumeric (valueset))
            catnames = arrayfun (@num2str, valueset, "UniformOutput", false);
          elseif (strcmp (classv, 'logical'))
            if (all (valueset))
              catnames = {'true'};
            elseif (! any (valueset))
              catnames = {'false'};
            else
              catnames = {'false'; 'true'};
            endif
          else  # datetime, duration
            catnames = dispstrings (valueset);
          endif
        endif

      else
        ## Resolve valueset and catnames from input array
        if (strcmp (classx, 'numeric'))
          valueset = unique (x);
          valueset = valueset(! isnan (valueset));
          catnames = arrayfun (@num2str, valueset, "UniformOutput", false);
        elseif (strcmp (classx, 'logical'))
          valueset = unique (x);
          if (all (valueset))
            catnames = {'true'};
          elseif (! any (valueset))
            catnames = {'false'};
          else
            catnames = {'false'; 'true'};
          endif
        elseif (strcmp (classx, 'cellstr'))
          valueset = unique (x);  # does not remove empty
          valueset = valueset(! cellfun (fmt, valueset));
          catnames = valueset;
        elseif (strcmp (classx, 'string'))
          valueset = unique (x);  # removes missing, but not empty
          valueset = valueset(! cellfun (fmt, cellstr (valueset)));
          catnames = cellstr (valueset);
        elseif (strcmp (classx, 'datetime'))
          valueset = unique (x);  # does not remove NaT
          valueset = valueset(! isnat (valueset));
          catnames = dispstrings (valueset);
        elseif (strcmp (classx, 'duration'))
          valueset = unique (x);  # does not remove NaN
          valueset = valueset(! ismissing (valueset));
          catnames = dispstrings (valueset);
        endif
      endif

      ## Associate input array with valueset
      [tf, loc] = ismember (x, valueset);
      maxc = intmax ('uint16');
      maxl = max (loc(:)) + sum (isnanvset);
      if (maxl > maxc)
        error (strcat ("categorical: too many categories; categorical", ...
                       " supports up to %d categories; this input has", ...
                       " %d."), maxc, maxl);
      endif

      ## If missing value in valueset is associated with a category name
      ## remove missing values in X from 'tf' and assign the index of the
      ## valueset that corresponds to the missing value to 'loc'
      if (any (isnanvset))
        tf = tf | isnanx;
        loc(find (isnanx)) = find (isnanvset);
      endif

      ## Reassociate to user defined categories (only when regrouping required)
      if (! isempty (index2cat))
        if (numel (index2cat) != unique (index2cat) || any (isnanvset))
          valueset = 1:prod (size (valueset));
          fcn = @ (x) index2cat(find (valueset == x));
          loc(tf) = arrayfun (fcn, loc(tf));
        endif
      endif

      ## Add constructor properties
      this.code = uint16 (loc);
      this.isMissing = ! tf;
      if (isempty (catnames))
        this.cats = {};
      else
        this.cats = unique (catnames(:), 'stable');
      endif


    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {categorical} {@var{cstr} =} dispstrings (@var{C})
    ##
    ## Get display formatted strings for each element of a categorical array.
    ##
    ## @code{@var{cstr} = dispstrings (@var{C})} returns a cellstr array of
    ## character vectors, @var{cstr}, which has the same size as the input
    ## categorical @var{C}.
    ##
    ## @end deftypefn
    function cstr = dispstrings (this)
      cstr = cell (size (this));
      ix = this.code(! this.isMissing);
      cstr(! this.isMissing) = this.cats(ix);
      cstr(this.isMissing) = {'<undefined>'};
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {categorical} {@var{cstr} =} cellstr (@var{C})
    ##
    ## Convert categorical array to a cell array of character vectors.
    ##
    ## @code{@var{cstr} = cellstr (@var{C})} returns a cellstr array of
    ## character vectors, @var{cstr}, which has the same size as the input
    ## categorical @var{C}.
    ##
    ## @end deftypefn
    function cstr = cellstr (this)
      cstr = dispstrings (this);
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {categorical} {@var{CM} =} char (@var{C})
    ##
    ## Convert categorical array to a 2-D character matrix.
    ##
    ## @code{@var{CM} = char (@var{C})} returns a character matrix @var{CM},
    ## which contains @code{numel (@var{C})} rows and each row contains the
    ## category name for the corresponding element of @code{@var{C}(:)}.
    ##
    ## @end deftypefn
    function CM = char (this)
      cstr = dispstrings (this);
      CM = char (cstr(:));
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {categorical} {@var{out} =} double (@var{C})
    ##
    ## Convert categorical array to a double array.
    ##
    ## @code{@var{out} = double (@var{C})} returns a double array indexing the
    ## categories in @var{C}.  Categorical elements of undefined category are
    ## returned as @code{NaN}.
    ##
    ## @end deftypefn
    function out = double (this)
      out = double (this.code);
      out(out == 0) = NaN;
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {categorical} {@var{out} =} single (@var{C})
    ##
    ## Convert categorical array to a single array.
    ##
    ## @code{@var{out} = single (@var{C})} returns a single array indexing the
    ## categories in @var{C}.  Categorical elements of undefined category are
    ## returned as @code{NaN}.
    ##
    ## @end deftypefn
    function out = single (this)
      out = single (this.code);
      out(out == 0) = NaN;
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {categorical} {@var{out} =} int64 (@var{C})
    ##
    ## Convert categorical array to a int64 array.
    ##
    ## @code{@var{out} = int64 (@var{C})} returns a @qcode{int64} array
    ## indexing the categories in @var{C}.  Categorical elements of undefined
    ## category are returned as @qcode{0}.
    ##
    ## @end deftypefn
    function out = int64 (this)
      out = int64 (this.code);
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {categorical} {@var{out} =} int32 (@var{C})
    ##
    ## Convert categorical array to a int32 array.
    ##
    ## @code{@var{out} = int32 (@var{C})} returns a @qcode{int32} array
    ## indexing the categories in @var{C}.  Categorical elements of undefined
    ## category are returned as @qcode{0}.
    ##
    ## @end deftypefn
    function out = int32 (this)
      out = int32 (this.code);
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {categorical} {@var{out} =} int16 (@var{C})
    ##
    ## Convert categorical array to a int16 array.
    ##
    ## @code{@var{out} = int16 (@var{C})} returns a @qcode{int16} array
    ## indexing the categories in @var{C}.  Categorical elements of undefined
    ## category are returned as @qcode{0}.  Note that the returned category
    ## indices saturate to @qcode{intmax ('int16')}, which is 32767.
    ##
    ## @end deftypefn
    function out = int16 (this)
      out = int16 (this.code);
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {categorical} {@var{out} =} int8 (@var{C})
    ##
    ## Convert categorical array to a int8 array.
    ##
    ## @code{@var{out} = int8 (@var{C})} returns a @qcode{int8} array
    ## indexing the categories in @var{C}.  Categorical elements of undefined
    ## category are returned as @qcode{0}.  Note that the returned category
    ## indices saturate to @qcode{intmax ('int8')}, which is 127.
    ##
    ## @end deftypefn
    function out = int8 (this)
      out = int8 (this.code);
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {categorical} {@var{out} =} uint64 (@var{C})
    ##
    ## Convert categorical array to a uint64 array.
    ##
    ## @code{@var{out} = uint64 (@var{C})} returns a @qcode{uint64} array
    ## indexing the categories in @var{C}.  Categorical elements of undefined
    ## category are returned as @qcode{0}.
    ##
    ## @end deftypefn
    function out = uint64 (this)
      out = uint64 (this.code);
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {categorical} {@var{out} =} uint32 (@var{C})
    ##
    ## Convert categorical array to a uint32 array.
    ##
    ## @code{@var{out} = uint32 (@var{C})} returns a @qcode{uint32} array
    ## indexing the categories in @var{C}.  Categorical elements of undefined
    ## category are returned as @qcode{0}.
    ##
    ## @end deftypefn
    function out = uint32 (this)
      out = uint32 (this.code);
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {categorical} {@var{out} =} uint16 (@var{C})
    ##
    ## Convert categorical array to a uint16 array.
    ##
    ## @code{@var{out} = uint16 (@var{C})} returns a @qcode{uint16} array
    ## indexing the categories in @var{C}.  Categorical elements of undefined
    ## category are returned as @qcode{0}.
    ##
    ## @end deftypefn
    function out = uint16 (this)
      out = uint16 (this.code);
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {categorical} {@var{out} =} uint8 (@var{C})
    ##
    ## Convert categorical array to a int8 array.
    ##
    ## @code{@var{out} = uint8 (@var{C})} returns a @qcode{uint8} array
    ## indexing the categories in @var{C}.  Categorical elements of undefined
    ## category are returned as @qcode{0}.  Note that the returned category
    ## indices saturate to @qcode{intmax ('uint8')}, which is 255.
    ##
    ## @end deftypefn
    function out = uint8 (this)
      out = uint8 (this.code);
    endfunction

  endmethods

################################################################################
##                         ** Summary Information **                          ##
################################################################################
##                             Available Methods                              ##
##                                                                            ##
## 'summary'          'categories'       'countcats'        'length'          ##
## 'size'             'ndims'            'numel'            'keyHash'         ##
##                                                                            ##
################################################################################

  methods (Access = public)

    ## -*- texinfo -*-
    ## @deftypefn  {categorical} {} summary (@var{C})
    ## @deftypefnx {categorical} {} summary (@var{C}, @var{dim})
    ## @deftypefnx {categorical} {} summary (@dots{}, @var{Name}, @var{Value})
    ## @deftypefnx {categorical} {@var{s} =} summary (@dots{})
    ##
    ## Display summary of categorical array.
    ##
    ## @code{summary (@var{C})} displays the number of elements in the
    ## categorical array @var{C} that are equal to each category of @var{C}.
    ## Any undefined elements in @var{C} are summed together and displayed as
    ## @qcode{<undefined>}.
    ##
    ## @end deftypefn
    function varargout = summary (this, varargin)

      ## Parse optional Name-Value paired arguments
      optName = {'Statistics'};
      dfValue = {'default'};
      [stats, args] = parsePairedArguments (optName, dfValue, varargin(:));

      ## Force stats to cellstr
      stats = cellstr (stats);

      ## Handle default statistcs
      didx = strcmpi (stats, 'default');
      if (this.isOrdinal)
        if (any (didx))
          default = {'counts', 'nummissing', 'min', 'median', 'max'};
          stats(didx) = [];
          ridx = cellfun (@(x) any (strcmpi (x, default)), stats);
          stats(ridx) = [];
          stats = [default, stats];
        endif
      else
        if (any (didx))
          default = {'counts', 'nummissing'};
          stats(didx) = [];
          ridx = cellfun (@(x) any (strcmpi (x, default)), stats);
          stats(ridx) = [];
          stats = [default, stats];
        endif
      endif

      ## Get operating dimension
      sz = size (this);
      if (isempty (args))
        (dim = find (sz > 1, 1)) || (dim = 1);
      else
        if (numel (args) > 1)
          error ("categorical.summary: unrecognized input argument.");
        endif
        dim = args{1};
        if (! (isnumeric (dim) && isscalar (dim) && fix (dim) == dim && ...
               dim > 0))
          error ("categorical.summary: DIM must be a positive integer.");
        endif
      endif

      ## Store input size and category names in output struct
      s.Size = sz;
      s.Type = 'categorical';
      s.Name = inputname (1);
      s.Categories = this.cats;
      ## Store 'counts' first (if requested)
      cidx = strcmpi (stats, 'counts');
      NAMES = {};
      if (any (cidx))
        NAMES = [NAMES; this.cats];
        s.Counts = countcats (this, dim);
        stats(cidx) = [];
        nidx = strcmpi (stats, 'nummissing');
        if (any (nidx))
          s.NumMissing = sum (this.isMissing, dim);
          stats(nidx) = [];
          NAMES = [NAMES; '<undefined>'];
        endif
      endif
      ## Store remaining statistics according to their order in 'stats'
      STATS = {};
      while (! isempty (stats))
        switch (lower (stats{1}))
          case 'nummissing'
            s.NumMissing = sum (this.isMissing, dim);
            STATS = [STATS; 'NumMissing'];
          case 'min'
            if (this.isOrdinal)
              s.Min = min (this, [], dim);
              STATS = [STATS; 'Min'];
            endif
          case 'median'
            if (this.isOrdinal)
              s.Median = median (this, dim);
              STATS = [STATS; 'Median'];
            endif
          case 'max'
            if (this.isOrdinal)
              s.Max = max (this, [], dim);
              STATS = [STATS; 'Max'];
            endif
          case 'mode'
            s.Mode = mode (this, dim);
            STATS = [STATS; 'Mode'];
        endswitch
        stats(1) = [];
      endwhile

      ## Return structure or display summary
      if (nargout > 0)
        s = rmfield (s, 'Name');
        varargout{1} = s;
      else
        __summary__ (s, dim, NAMES, STATS);
      endif
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {categorical} {@var{cstr} =} categories (@var{C})
    ##
    ## List of categories in categorical array.
    ##
    ## @code{@var{cstr} = categories (@var{C})} returns a cell array of
    ## character vectors with the names of the categories in @var{C}.
    ##
    ## @end deftypefn
    function cstr = categories (this)
      cstr = this.cats;
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn  {categorical} {@var{N} =} countcats (@var{C})
    ## @deftypefnx {categorical} {@var{N} =} countcats (@var{C}, @var{dim})
    ##
    ## Count occurrences of categories in a categorical array.
    ##
    ## @code{@var{N} = countcats (@var{C})} returns the number of elements for
    ## each category in @var{C}.  If @var{C} is a vector, @var{N} is also a
    ## vector with one element for each category in @var{C}.  If @var{C} is a
    ## matrix, @var{N} is a matrix with each column containing the category
    ## counts from each column of @var{C}.  For multidimensional arrays,
    ## @code{countcats} operates along the first non-singleton dimension.
    ##
    ## @code{@var{N} = countcats (@var{C}, @var{dim})} operates along the
    ## dimension @var{dim}.
    ##
    ## @end deftypefn
    function N = countcats (this, dim = [])
      codes = this.code;
      sz = size (codes);
      nc = numel (this.cats);
      if (isempty (dim))
        (dim = find (sz > 1, 1)) || (dim = 1);
      elseif (! (isnumeric (dim) && isscalar (dim) && fix (dim) == dim && ...
                 dim > 0))
        error ("categorical.countcats: DIM must be a positive integer.");
      endif
      if (nc == 0)
        sz(dim) = 0;
        N = zeros (sz);
        return;
      endif
      if (dim <= ndims (this))
        nsz = sz;
        nsz(dim) = nc;

        ## Look-up indices.
        grp = [1:nc];

        idx = lookup (grp, codes);
        ## Zero invalid ones (including NaNs).  x < edges(1) are already zero.
        idx(! (codes <= grp(end))) = 0;

        iidx = idx;

        ## In case of matrix input, we adjust the indices.
        no_vector = (isrow (codes) && dim == 1) || ...
                    (iscolumn (codes) && dim == 2);
        if (! isvector (codes) || no_vector)
          nl = prod (sz(1:dim-1));
          nn = sz(dim);
          nu = prod (sz(dim+1:end));
          if (nl != 1)
            iidx = (iidx-1) * nl;
            iidx += reshape (kron (ones (1, nn*nu), 1:nl), sz);
          endif
          if (nu != 1)
            ne = length (grp);
            iidx += reshape (kron (nl*ne*(0:nu-1), ones (1, nl*nn)), sz);
          endif
        endif

        ## Select valid elements.
        iidx = iidx(idx != 0);

        ## Call accumarray to sum the indexed elements.
        N = accumarray (iidx(:), 1, nsz);
      else
        N = repmat (zeros (size (this)), [ones(1, dim - 1), nc]);
        totnum = prod (size (this));
        linvec = 1:totnum;
        for i = 1:nc
          offset = (i - 1) * totnum;
          N(linvec + offset) = codes == i;
        endfor
      endif
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {categorical} {@var{N} =} length (@var{C})
    ##
    ## Length of a categorical vector.
    ##
    ## @code{@var{N} = length (@var{C})} returns the size of the longest
    ## dimension of the categorical array @var{C}, unless any of its dimensions
    ## has zero length, in which case @code{length (@var{C})} returns 0.
    ##
    ## @end deftypefn
    function N = length (this)
      if (isempty (this.code))
        N = 0;
      else
        N = max (size (this.code));
      endif
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn  {categorical} {@var{sz} =} size (@var{C})
    ## @deftypefnx {categorical} {@var{dim_sz} =} size (@var{C}, @var{dim})
    ## @deftypefnx {categorical} {@var{dim_sz} =} size (@var{C}, @var{d1}, @var{d2}, @dots{})
    ## @deftypefnx {categorical} {[@var{rows}, @var{columns}, @dots{}, @var{dim_n_sz}] =} size (@dots{})
    ##
    ## Size of a categorical array.
    ##
    ## @code{@var{sz} = size (@var{C})} returns a row vector with the size
    ## (number of elements) of each dimension for the categorical array @var{C}.
    ##
    ## @code{@var{dim_sz} = size (@var{C}, @var{dim})} returns the size of
    ## the corresponding dimension specified in @var{dim}.  If @var{dim} is a
    ## vector, then @var{dim_sz} is a vector of the same length and with each
    ## element corresponding to a specified dimension.  Multiple dimensions may
    ## also be specified as separate arguments.
    ##
    ## With a single output argument, @code{size} returns a row vector.  With
    ## several, the size of dimension N is returned in the Nth argument.
    ## Asking for fewer output arguments than the array has dimensions folds
    ## the trailing dimensions into the last one, so @code{[r, c] = size
    ## (@var{C})} on a 2-by-2-by-2 array returns 2 and 4; asking for more pads
    ## with ones.  When a dimension is named, however, the number of output
    ## arguments must equal the number of dimensions requested.
    ##
    ## @end deftypefn
    function varargout = size (this, varargin)
      if (! isempty (varargin))
        ## A named dimension fixes how many outputs there may be.
        sz = size (this.code, varargin{:});
        if (nargout > 1 && numel (sz) != nargout)
          error (strcat ("categorical.size: nargout does not match the", ...
                         " number of requested dimensions."));
        endif
      else
        sz = size (this.code);
        ## With no dimension named, asking for more outputs than the array has
        ## dimensions pads with ones, and asking for fewer folds the trailing
        ## dimensions into the last output, as core Octave and MATLAB both do.
        if (nargout > numel (sz))
          sz = [sz, ones(1, nargout - numel (sz))];
        elseif (nargout > 1 && nargout < numel (sz))
          sz = [sz(1:nargout-1), prod(sz(nargout:end))];
        endif
      endif
      if (nargout == 0 || nargout == 1)
        varargout{1} = sz;
      else
        for i = 1:nargout
          varargout{i} = sz(i);
        endfor
      endif
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {categorical} {@var{out} =} ndims (@var{C})
    ##
    ## Number of dimensions in a categorical array.
    ##
    ## @code{@var{out} = ndims (@var{C})} returns the number of dimensions of
    ## the categorical array @var{C}.
    ##
    ## @end deftypefn
    function out = ndims (this)
      out = ndims (this.code);
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {categorical} {@var{out} =} numel (@var{C})
    ##
    ## Total number of elements in a categorical array.
    ##
    ## @code{@var{out} = numel (@var{C})} returns the number of elements in the
    ## categorical array @var{C}.
    ##
    ## @end deftypefn
    function out = numel (this, varargin)
      out = numel (this.code);
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn  {categorical} {@var{key} =} keyHash (@var{C})
    ## @deftypefnx {categorical} {@var{key} =} keyHash (@var{C}, @var{base})
    ##
    ## Generate a hash code for a categorical array.
    ##
    ## @code{@var{key} = keyHash (@var{C})} generates a @qcode{uint64} scalar
    ## that represents the input array @var{C}.  @code{keyHash} utilizes the
    ## 64-bit FNV-1a variant of the Fowler-Noll-Vo non-cryptographic hash
    ## function.
    ##
    ## @code{@var{key} = keyHash (@var{C}, @var{base})} also generates a 64-bit
    ## hash code using @var{base} as the offset basis for the FNV-1a hash
    ## algorithm.  @var{base} must be a @qcode{uint64} integer type scalar.  Use
    ## this syntax to cascade @code{keyHash} on multiple objects for which a
    ## single hash code is required.
    ##
    ## A categorical array is keyed on its element labels, on its ordinality,
    ## and -- for an ordinal array alone -- on the order of its categories,
    ## which is what its comparisons mean.  Neither the @qcode{Protected}
    ## property nor a non-ordinal array's category list is part of the key, so
    ## two non-ordinal arrays carrying the same labels hash alike however
    ## their categories differ or are ordered.  Undefined elements are equal
    ## to one another and distinct from every category.  Two arrays that
    ## @code{keyMatch} reports as the same key always hash alike.
    ##
    ## Note that unlike MATLAB, this implementation does not use any random
    ## seed.  As a result, @code{keyHash} will always generate the exact same
    ## hash key for any particular input across different workers and Octave
    ## sessions.
    ##
    ## @end deftypefn
    function key = keyHash (this, base = [])
      ## Initialize string with size, class name, and ordinality.  What is
      ## deliberately absent is as important as what is here.  The Protected
      ## flag never affects equality, so it is not part of the key.  Neither
      ## is the category list of a NON-ordinal array: two non-ordinal arrays
      ## carrying the same labels are the same key however their categories
      ## differ, or are ordered.  For an ORDINAL array the ordering is what
      ## the comparisons mean and is therefore part of identity, so the
      ## category list is hashed, in order, for those alone.
      size_str = sprintf ('%dx', size (this.code))(1:end-1);
      flag_str = sprintf ('-o%d:', this.isOrdinal);
      init_str = [size_str class(this) flag_str];
      if (! isempty (base))
        if (! (isscalar (base) && isa (base, 'uint64')))
          error ("categorical.keyHash: BASE must be a UINT64 scalar.");
        endif
        key = __ckeyHash__ (init_str, base);
      else
        key = __ckeyHash__ (init_str);
      endif
      if (this.isOrdinal)
        key = __ckeyHash__ (this.cats, key);
      endif
      if (! isempty (this.code))
        ## The element LABELS, framed, and not the code.  The code is only a
        ## label's position in the category list, so two equal non-ordinal
        ## arrays whose categories were declared in a different order carry
        ## different codes -- hashing it would break the contract for exactly
        ## the case being fixed.  Undefined elements are carried by the
        ## isMissing mask instead of by a substituted label, so no real
        ## category can be confused with one.
        labels = cell (size (this.code));
        labels(:) = {''};
        defined = this.code != 0;
        labels(defined) = this.cats(this.code(defined));
        key = __ckeyHash__ (labels, key);
        key = __nkeyHash__ (this.isMissing(:), key);
      endif
    endfunction

  endmethods

################################################################################
##                          ** Query Operations **                            ##
################################################################################
##                             Available Methods                              ##
##                                                                            ##
## 'iscategory'       'iscolumn'         'isempty'          'isequal'         ##
## 'isequaln'         'ismatrix'         'ismember'         'ismissing'       ##
## 'isordinal'        'isprotected'      'isrow'            'isscalar'        ##
## 'issorted'         'issortedrows'     'isundefined'      'isvector'        ##
##                                                                            ##
################################################################################

  methods (Access = public)

    ## -*- texinfo -*-
    ## @deftypefn {categorical} {@var{TF} =} iscategory (@var{C}, @var{catnames})
    ##
    ## Test for categories in a categorical array.
    ##
    ## @code{@var{TF} = iscategory (@var{C}, @var{catnames})} returns a logical
    ## array @var{TF} of the same size as @var{catnames} containing @qcode{true}
    ## for each corresponding element of @var{catnames} that is a category in
    ## categorical array @var{C} and @qcode{false} otherwise.
    ##
    ## @end deftypefn
    function TF = iscategory (this, catnames)
      if (nargin < 2)
        error ("categorical.iscategory: CATNAMES input argument is required.");
      endif
      catnames = cellstr (catnames);
      TF = ismember (catnames, this.cats);
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {categorical} {@var{TF} =} iscolumn (@var{C})
    ##
    ## Return true if categorical array is a column vector.
    ##
    ## @code{@var{TF} = iscolumn (@var{C})} returns a logical scalar @var{TF},
    ## which is @qcode{true}, if the categorical array @var{C} is a column
    ## vector, and @qcode{false} otherwise.  A column vector is a 2-D array for
    ## which @code{size (@var{X})} returns @code{[@var{N}, 1]} with non-negative
    ## @var{N}.
    ##
    ## @end deftypefn
    function TF = iscolumn (this)
      TF = iscolumn (this.code);
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {categorical} {@var{TF} =} isempty (@var{C})
    ##
    ## Return true if categorical array is empty.
    ##
    ## @code{@var{TF} = isempty (@var{C})} returns a logical scalar @var{TF},
    ## which is @qcode{true}, if the categorical array @var{C} is empty, and
    ## @qcode{false} otherwise.
    ##
    ## @end deftypefn
    function TF = isempty (this)
      TF = isempty (this.code);
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn  {categorical} {@var{TF} =} isequal (@var{C1}, @var{C2})
    ## @deftypefnx {categorical} {@var{TF} =} isequal (@var{C1}, @var{C2}, @dots{})
    ##
    ## Return true if categorical arrays are equal.
    ##
    ## @code{@var{TF} = isequal (@var{C1}, @var{C2})} returns a logical scalar
    ## @var{TF}, which is @qcode{true}, if the categorical arrays @var{C1} and
    ## @var{C2} contain the same values, and @qcode{false} otherwise.  Either
    ## @var{C1} or @var{C2} may also be a string array, a missing object array,
    ## a character vector, or a cell array of character vectors, which will be
    ## promoted to a categorical array prior to comparison.
    ##
    ## If categorical arrays @var{C1} and @var{C2} are ordinal, they must have
    ## the same set and ordering of categories.  If neither are ordinal, the
    ## category names of each pair of elements are compared.  Hence, they do not
    ## need to have the same set of categories.
    ##
    ## @code{@var{TF} = isequal (@var{C1}, @var{C2}, @dots{})} returns a logical
    ## scalar @var{TF}, which is @qcode{true}, if all input arguments are equal,
    ## and @qcode{false} otherwise.
    ##
    ## @end deftypefn
    function TF = isequal (varargin)
      args = varargin;
      [args{:}] = promote (varargin{:});
      ## Check for undefined elements (result to false)
      if (any (cellfun (@(x) any (x.code == 0, 'all'), args)))
        TF = false;
        return;
      endif
      ## If any categorical value is ordinal, all must be
      is_ordinal = cellfun (@isordinal, args);
      if (all (is_ordinal))
        ## Check that all categorical arrays have the same categories
        ## and they are in the same order
        cats = cellfun (@(x) categories (x), args, 'UniformOutput', false);
        if (! isequal (cats{:}))
          TF = false;
          return;
        endif
        fieldArgs = cellfun (@(x) x.code, args, 'UniformOutput', false);
        TF = isequal (fieldArgs{:});
      elseif (any (is_ordinal))
        TF = false;
      else
        ## Compare the category names of each element
        fcn = @(x) reshape (x.cats(x.code), size (x.code));
        fieldArgs = cellfun (fcn, args, 'UniformOutput', false);
        TF = isequal (fieldArgs{:});
      endif
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn  {categorical} {@var{TF} =} isequaln (@var{C1}, @var{C2})
    ## @deftypefnx {categorical} {@var{TF} =} isequaln (@var{C1}, @var{C2}, @dots{})
    ##
    ## Return true if categorical arrays are equal under the assumption that
    ## undefined elements are equal.
    ##
    ## @code{@var{TF} = isequaln (@var{C1}, @var{C2})} returns a logical scalar
    ## @var{TF}, which is @qcode{true}, if the categorical arrays @var{C1} and
    ## @var{C2} contain the same values or corresponding undefined elements, and
    ## @qcode{false} otherwise.  Either @var{C1} or @var{C2} may also be a
    ## string array, a missing object array, a character vector, or a cell array
    ## of character vectors, which will be promoted to a categorical array prior
    ## to comparison.
    ##
    ## If categorical arrays @var{C1} and @var{C2} are ordinal, they must have
    ## the same set and ordering of categories.  If neither are ordinal, the
    ## category names of each pair of elements are compared.  Hence, they do not
    ## need to have the same set of categories.
    ##
    ## @code{@var{TF} = isequaln (@var{C1}, @var{C2}, @dots{})} returns a
    ## logical scalar @var{TF}, which is @qcode{true}, if all input arguments
    ## are equal, and @qcode{false} otherwise.
    ##
    ## @end deftypefn
    function TF = isequaln (varargin)
      args = varargin;
      [args{:}] = promote (varargin{:});
      ## If any categorical value is ordinal, all must be
      is_ordinal = cellfun (@isordinal, args);
      if (all (is_ordinal))
        ## Check that all categorical arrays have the same categories
        ## and they are in the same order
        cats = cellfun (@(x) categories (x), args, 'UniformOutput', false);
        if (! isequal (cats{:}))
          TF = false;
          return;
        endif
        fieldArgs = cellfun (@(x) x.code, args, 'UniformOutput', false);
        TF = isequal (fieldArgs{:});
      elseif (any (is_ordinal))
        TF = false;
      else
        ## Compare the category names of each element
        fcn = @(x) reshape ((['<undefined>'; x.cats])(x.code + 1), ...
                            size (x.code));
        fieldArgs = cellfun (fcn, args, 'UniformOutput', false);
        TF = isequal (fieldArgs{:});
      endif
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {categorical} {@var{TF} =} ismatrix (@var{C})
    ##
    ## Return true if categorical array is a 2-D array.
    ##
    ## @code{@var{TF} = ismatrix (@var{C})} returns a logical scalar @var{TF},
    ## which is @qcode{true}, if the categorical array @var{C} is a matrix, and
    ## @qcode{false} otherwise.  A matrix is an array of any type where
    ## @code{ndims (@var{X}) == 2} and for which @code{size (@var{X})} returns
    ## @code{[@var{H}, @var{W}]} with non-negative @var{H} and @var{W}.
    ##
    ## @end deftypefn
    function TF = ismatrix (this)
      TF = ismatrix (this.code);
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn  {categorical} {@var{TF} =} ismember (@var{A}, @var{B})
    ## @deftypefnx {categorical} {@var{TF} =} ismember (@var{A}, @var{B}, @qcode{'rows'})
    ## @deftypefnx {categorical} {[@var{TF}, @var{index}] =} ismember (@dots{})
    ## @deftypefnx {categorical} {[@var{TF}, @var{index}] =} ismember (@dots{}, @qcode{'legacy'})
    ##
    ## Find categorical elements in a set.
    ##
    ## @code{@var{TF} = ismember (@var{A}, @var{B})} returns a logical array
    ## @var{TF} of the same size as @var{A} containing @qcode{true} for each
    ## corresponding element of @var{A} that is in @var{B} and @qcode{false}
    ## otherwise.  @qcode{<undefined>} elements are not equal with each other
    ## and always return @qcode{false}.  If @var{A} and @var{B} are ordinal,
    ## they must have the same ordered set of categories.  If neither @var{A}
    ## nor @var{B} are ordinal, then this restriction is relaxed and comparison
    ## is performed using the category names.  Comparison between an ordinal and
    ## an unordered categorical array is not allowed.  Either @var{A} or @var{B}
    ## may also be a string array, a character vector, or a cell array of
    ## character vectors containing one or multiple category names to compare
    ## against an unordered categorical array.
    ##
    ## @code{@var{TF} = ismember (@var{A}, @var{B}, @qcode{'rows'})} only
    ## applies to categorical matrices with the same number of columns, in which
    ## case the logical vector @var{TF} contains @qcode{true} for each row of
    ## @var{A} that is also a row in @var{B}.  @var{TF} has the same number of
    ## rows as @var{A}.
    ##
    ## @code{[@var{TF}, @var{index}] = ismember (@var{A}, @var{B})} also returns
    ## an index array of the same size as @var{A} containing the lowest index in
    ## @var{B} for each element of @var{A} that is a member of @var{B} and 0
    ## otherwise.  If the @qcode{'rows'} optional argument is used, then the
    ## returning index is a column vector with the same rows as @var{A} and it
    ## contains the lowest index in @var{B} for each row of @var{A} that is a
    ## member of @var{B} and 0 otherwise.  If the @qcode{'legacy'} optional
    ## argument is specified, then the highest index of matched elements is
    ## returned.  Unless multiple matches exist, the @qcode{'legacy'} option has
    ## no effect on the returned @var{index}.
    ##
    ## @end deftypefn
    function varargout = ismember (A, B, varargin)
      ## Either A or B contain categorical text representation
      if ((ischar (A) && isrow (A)) || isstring (A) || iscellstr (A))
        if (ischar (A))
          A = cellstr (A);
        endif
        A = categorical (A);
      elseif ((ischar (B) && isrow (B)) || isstring (B) || iscellstr (B))
        if (ischar (B))
          B = cellstr (B);
        endif
        B = categorical (B);
      elseif (! (iscategorical (A) && iscategorical (B)))
        error (strcat ("categorical.ismember: A and B must be categorical", ...
                       " arrays, or one of them (A or B) can be a string", ...
                       " array, a character vector, or a cell array of", ...
                       " character vectors specifying category names."));
      endif
      ## Check for 'rows' and 'legacy' optional arguments
      if (! isempty (varargin))
        if (! cellfun ('ischar', varargin));
          error (strcat ("categorical.ismember: all options must be", ...
                         " character vectors."));
        elseif (! all (strcmpi (varargin, 'rows') | ...
                       strcmpi (varargin, 'legacy')))
          error (strcat ("categorical.ismember: only 'rows' and", ...
                         " 'legacy' are valid options."));
        endif
        if (any (strcmpi ('rows', varargin)))
          if (ndims (A) != 2 || ndims (A) != ndims (B))
            error (strcat ("categorical.ismember: 'rows' applies only", ...
                           " to 2-D matrices."));
          endif
          if (size (A, 2) != size (B, 2))
            error (strcat ("categorical.ismember: 'rows' requires same", ...
                           " number of columns."));
          endif
        endif
      endif
      ## Handle empty input array
      if (isempty (A) || isempty (B))
        sz = size (A);
        varargout{1} = false (sz);
        if (nargout > 1)
          varargout{2} = zeros (sz);
        endif
        return;
      endif
      ## Both ordinal
      if (isordinal (A) && isordinal (B))
        if (! isequal (categories (A), categories (B)))
          error (strcat ("categorical.ismember: ordinal categorical arrays", ...
                         " must have the same ordered set of categories."));
        endif
        if (nargout > 1)
          [varargout{1}, varargout{2}] = __ismember__ (double (A), ...
                                                       double (B), varargin{:});
        else
          varargout{1} = __ismember__ (double (A), double (B), varargin{:});
        endif
      elseif (isordinal (A) || isordinal (B))
        error (strcat ("categorical.ismember: both categorical arrays", ...
                       " must be ordinal."));
      else
        ## Compare the category names of each element (except undefined)
        A_idx = A.code != 0;
        B_idx = B.code != 0;
        cats_A = string (cell (size (A.code)));
        cats_B = string (cell (size (B.code)));
        cats_A(A_idx) = A.cats(A.code(A_idx));
        cats_B(B_idx) = B.cats(B.code(B_idx));
        if (nargout > 1)
          [varargout{1}, varargout{2}] = ismember (cats_A, cats_B, varargin{:});
        else
          varargout{1} = ismember (cats_A, cats_B, varargin{:});
        endif
      endif
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn  {categorical} {@var{out} =} ismissing (@var{C})
    ## @deftypefnx {categorical} {@var{out} =} ismissing (@var{C}, @var{indicator})
    ##
    ## Find missing elements in categorical array.
    ##
    ## @code{@var{TF} = ismissing (@var{C})} returns a logical array @var{TF}
    ## of the same size as @var{C} containing @qcode{true} for each
    ## corresponding element of @var{C} that does not have a value from one of
    ## the categories in @var{C} and @qcode{false} otherwise.
    ##
    ## @code{@var{TF} = ismissing (@var{C}, @var{indicator})} also returns a
    ## logical array @var{TF} containing @qcode{true} for each corresponding
    ## element of @var{C} that does not have a value from one of the categories
    ## specified in @var{indicator} and @qcode{false} otherwise.
    ##
    ## @var{indicator} must be either a categorical array or a character vector
    ## or a string vector or a cell vector of character vectors.  When the
    ## @var{indicator} contains text representation, the comparison is based on
    ## lexicographical equality to the category names of @var{C}.
    ##
    ## @end deftypefn
    function TF = ismissing (this, varargin)
      if (nargin > 2)
        error ("categorical.ismissing: too many input arguments.");
      endif
      if (! isempty (varargin))
        indicator = varargin{1};
        TF = false (size (this));
        if (isvector (indicator))
          if (ischar (indicator))
            if (! isrow (indicator))
              error (strcat ("categorical.ismissing: INDICATOR specified", ...
                             " as a character vector must be a row vector."));
            endif
            TF(this == indicator) = true;
          elseif (isstring (indicator) || iscellstr (indicator))
            for i = 1:numel (indicator)
              TF(this == indicator(i)) = true;
            endfor
          elseif (isa (indicator, 'categorical'))
            for i = 1:length (indicator)
              idx = indicator.code(i);
              if (idx)
                cat = indicator.cats{idx};
                TF(this == cat) = true;
              else  # code = 0 corresponds to undefined categories
                TF(this.isMissing) = true;
              endif
            endfor
          else
            error (strcat ("categorical.ismissing: INDICATOR must be", ...
                           " a text representation of categories or", ...
                           " categorical."));
          endif
        else
          error ("categorical.ismissing: INDICATOR must be a vector.");
        endif
      else
        TF = this.isMissing;
      endif
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {categorical} {@var{TF} =} isordinal (@var{C})
    ##
    ## Test if categorical array is ordinal.
    ##
    ## @code{@var{TF} = isordinal (@var{C})} returns a logical scalar @var{TF},
    ## which is @qcode{true}, if the categorical array @var{C} is ordinal, and
    ## @qcode{false} otherwise.
    ##
    ## @end deftypefn
    function TF = isordinal (this)
      TF = this.isOrdinal;
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {categorical} {@var{TF} =} isprotected (@var{C})
    ##
    ## Test if categorical array is protected.
    ##
    ## @code{@var{TF} = isprotected (@var{C})} returns a logical scalar
    ## @var{TF}, which is @qcode{true}, if the categorical array @var{C} is
    ## protected, and @qcode{false} otherwise.
    ##
    ## @end deftypefn
    function TF = isprotected (this)
      TF = this.isProtected;
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {categorical} {@var{TF} =} isrow (@var{C})
    ##
    ## Return true if categorical array is a row vector.
    ##
    ## @code{@var{TF} = isrow (@var{C})} returns a logical scalar @var{TF},
    ## which is @qcode{true}, if the categorical array @var{C} is a row vector,
    ## and @qcode{false} otherwise.  A row vector is a 2-D array for which
    ## @code{size (@var{X})} returns @code{[1, @var{N}]} with non-negative
    ## @var{N}.
    ##
    ## @end deftypefn
    function TF = isrow (this)
      TF = isrow (this.code);
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {categorical} {@var{TF} =} isscalar (@var{C})
    ##
    ## Return true if categorical array is a scalar.
    ##
    ## @code{@var{TF} = isscalar (@var{C})} returns a logical scalar @var{TF},
    ## which is @qcode{true}, if the categorical array @var{C} is also a scalar,
    ## and @qcode{false} otherwise.  A scalar is a single element object for
    ## which @code{size (@var{X})} returns @code{[1, 1]}.
    ##
    ## @end deftypefn
    function TF = isscalar (this)
      TF = isscalar (this.code);
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn  {categorical} {@var{TF} =} issorted (@var{C})
    ## @deftypefnx {categorical} {@var{TF} =} issorted (@var{C}, @var{dim})
    ## @deftypefnx {categorical} {@var{TF} =} issorted (@var{C}, @var{direction})
    ## @deftypefnx {categorical} {@var{TF} =} issorted (@var{C}, @var{dim}, @var{direction})
    ## @deftypefnx {categorical} {@var{TF} =} issorted (@dots{}, @qcode{'MissingPlacement'}, @var{MP})
    ##
    ## Return true if categorical array is sorted.
    ##
    ## @code{@var{TF} = issorted (@var{C})} returns a logical scalar @var{TF},
    ## which is @qcode{true}, if the categorical array @var{C} is sorted in
    ## ascending order, and @qcode{false} otherwise.
    ##
    ## @code{@var{TF} = issorted (@var{C}, @var{dim})} returns a logical scalar
    ## @var{TF}, which is @qcode{true}, if the categorical array @var{C} is
    ## sorted in ascending order along the dimension @var{dim}, and
    ## @qcode{false} otherwise.
    ##
    ## @code{@var{TF} = issorted (@var{A}, @var{direction})} returns a logical
    ## scalar @var{TF}, which is @qcode{true}, if the categorical array @var{C}
    ## is sorted in the direction specified by @var{direction}, and
    ## @qcode{false} otherwise.  @var{direction} can be one of the following
    ## options:
    ##
    ## @itemize
    ## @item @qcode{'ascend'}, which is the default, checks is elements are in
    ## ascending order.
    ## @item @qcode{'descend'} checks if elements are in descending order.
    ## @item @qcode{'monotonic'} checks if elements are either in ascending or
    ## descending order.
    ## @item @qcode{'strictascend'} checks if elements are in ascending order
    ## and there are no duplicate or undefined elements.
    ## @item @qcode{'strictdescend'} checks if elements are in descending order
    ## and there are no duplicate or undefined elements.
    ## @item @qcode{'strictmonotonic'} checks if elements are either in
    ## ascending or descending order and there are no duplicate or undefined
    ## elements.
    ## @end itemize
    ##
    ## @code{@var{TF} = issorted (@dots{}, @qcode{'MissingPlacement'},
    ## @var{MP})} specifies where missing elements (@qcode{<undefined>}) are
    ## placed with
    ## one of the following options specified in @var{MP}:
    ##
    ## @itemize
    ## @item @qcode{'auto'}, which is the default, places missing elements last
    ## for ascending sort and first for descending sort.
    ## @item @qcode{'first'} places missing elements first.
    ## @item @qcode{'last'} places missing elements last.
    ## @end itemize
    ##
    ## @end deftypefn
    function TF = issorted (this, varargin)
      ## Single input argument
      if (nargin == 1)
        TF = isequaln (this, sort (this));
        return;
      endif

      ## Get operating dimension
      cid = cellfun (@isnumeric, varargin);
      if (any (cid))
        dim = varargin{cid};
      else
        sz = size (this);
        dim = find (sz != 1, 1);
        if (isempty (dim)) # scalar
          dim = 1;
        endif
      endif

      ## Parse and validate optional 'MissingPlacement' paired argument
      optNames = {'MissingPlacement'};
      dfValues = {'auto'};
      [MP, args] = parsePairedArguments (optNames, dfValues, varargin(:));
## The values are matched without regard to case, as MATLAB does.
MP = lower (MP);
      if (! ismember (MP, {'auto', 'first', 'last'}))
        error ("categorical.issorted: invalid value for 'MissingPlacement'.");
      endif

      ## Force strings to character vectors
      [args{:}] = convertStringsToChars (args{:});

      ## Get direction
      cid = cellfun (@(x) ischar (x), args);
      if (any (cid))
        direction = lower (args{cid});
        ## Check for type of direction
        valid_direction = {'ascend', 'descend', 'monotonic', 'strictascend', ...
                           'strictdescend', 'strictmonotonic'};
        if (! ismember (direction, valid_direction))
          error ("categorical.issorted: invalid DIRECTION value.");
        endif
        switch (direction)
          case {'ascend', 'descend'}
            TF = isequaln (this, sort (this, args{:}, 'MissingPlacement', MP));

          case {'strictascend', 'strictdescend'}
            ## Check for missing values first (fast)
            if (any (this.isMissing, 'all'))
              TF = false;
              return;
            endif
            args{cid} = strrep (direction, 'strict', '');
            if (! strcmp (MP, 'auto'))
              args = [args, 'MissingPlacement', MP];
            endif
            sorted = sort (this, args{:});
            if (any (diff (double (sorted), 1, dim) == 0, 'all'))
              TF = false;
              return;
            endif
            TF = isequal (this, sorted);

          case 'monotonic'
            ## Check for either ascending or descending
            args{cid} = 'ascend';
            TF = isequaln (this, sort (this, args{:}, 'MissingPlacement', MP));
            if (TF)
              return;
            endif
            args{cid} = 'descend';
            TF = isequaln (this, sort (this, args{:}, 'MissingPlacement', MP));

          case 'strictmonotonic'
            ## Check missing values first (fast)
            if (any (this.isMissing, 'all'))
              TF = false;
              return;
            endif
            ## Check for either ascending or descending
            args{cid} = 'ascend';
            sorted = sort (this, args{:}, 'MissingPlacement', MP);
            if (any (diff (double (sorted), 1, dim) == 0, 'all'))
              TF = false;
              return;
            endif
            TF = isequal (this, sorted);
            if (TF)
              return;
            endif
            args{cid} = 'descend';
            sorted = sort (this, args{:}, 'MissingPlacement', MP);
            if (any (diff (double (sorted), 1, dim) == 0, 'all'))
              TF = false;
              return;
            endif
            TF = isequal (this, sorted);
        endswitch
      else
        ## No DIRECTION input argument
        TF = isequaln (this, sort (this, args{:}, 'MissingPlacement', MP));
      endif
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn  {categorical} {@var{TF} =} issortedrows (@var{C})
    ## @deftypefnx {categorical} {@var{TF} =} issortedrows (@var{C}, @var{col})
    ## @deftypefnx {categorical} {@var{TF} =} issortedrows (@var{C}, @var{direction})
    ## @deftypefnx {categorical} {@var{TF} =} issortedrows (@var{C}, @var{col}, @var{direction})
    ## @deftypefnx {categorical} {@var{TF} =} issortedrows (@dots{}, @qcode{'MissingPlacement'}, @var{MP})
    ##
    ## Return true if categorical matrix rows are sorted.
    ##
    ## @code{@var{TF} = issortedrows (@var{C})} returns a logical scalar
    ## @var{TF}, which is @qcode{true}, if the rows in the 2-D categorical array
    ## @var{C} are sorted in ascending order, and @qcode{false} otherwise.
    ##
    ## @code{@var{TF} = issortedrows (@var{C}, @var{col})} returns a logical
    ## scalar @var{TF}, which is @qcode{true}, if the categorical array @var{C}
    ## is sorted according to the columns specified by the vector @var{col}, and
    ## @qcode{false} otherwise.  @var{col} must explicitly contain non-zero
    ## integers whose absolute values index  existing columns in @var{C}.
    ## Positive elements sort the corresponding columns in ascending order,
    ## while negative elements sort the corresponding columns in descending
    ## order.
    ##
    ## @code{@var{TF} = issortedrows (@var{C}, @var{direction})} checks if the
    ## rows in @var{C} are sorted according to the specified direction, which
    ## can be one of the following options:
    ##
    ## @itemize
    ## @item @qcode{'ascend'}, which is the default, checks is elements are in
    ## ascending order.
    ## @item @qcode{'descend'} checks if elements are in descending order.
    ## @item @qcode{'monotonic'} checks if elements are either in ascending or
    ## descending order.
    ## @item @qcode{'strictascend'} checks if elements are in ascending order
    ## and there are no duplicate or undefined elements.
    ## @item @qcode{'strictdescend'} checks if elements are in descending order
    ## and there are no duplicate or undefined elements.
    ## @item @qcode{'strictmonotonic'} checks if elements are either in
    ## ascending or descending order and there are no duplicate or undefined
    ## elements.
    ## @end itemize
    ##
    ## Alternatively, @var{direction} can be a cell array of character
    ## vectors specifying the sorting direction for each individual column of
    ## @var{C}, in which case the number of elements in @var{direction} must
    ## equal the number of columns in @var{C}.
    ##
    ## @code{@var{TF} = issortedrows (@var{C}, @var{col}, @var{direction})}
    ## checks if the rows in the categorical array @var{C} are sorted according
    ## to the columns specified in @var{col} using the corresponding sorting
    ## direction specified in @var{direction}.  In this case, the sign of the
    ## values in @var{col} is ignored.  @var{col} and @var{direction} must have
    ## the same length, but not necessarily the same number of elements as the
    ## columns in @var{C}.
    ##
    ## @code{@var{TF} = issortedrows (@dots{}, @qcode{'MissingPlacement'},
    ## @var{MP})} specifies where missing elements (@qcode{<undefined>}) are
    ## placed with one of the following options specified in @var{MP}:
    ##
    ## @itemize
    ## @item @qcode{'auto'}, which is the default, places missing elements last
    ## for ascending sort and first for descending sort.
    ## @item @qcode{'first'} places missing elements first.
    ## @item @qcode{'last'} places missing elements last.
    ## @end itemize
    ##
    ## @end deftypefn
    function TF = issortedrows (this, varargin)
      ## Single input argument
      if (nargin == 1)
        TF = isequaln (this, sortrows (this));
        return;
      endif

      ## Force strings to character vectors or cell arrays of character vectors
      [varargin{:}] = convertStringsToChars (varargin{:});

      ## Get valid direction(s) from input argument list
      valid = {'ascend', 'descend', 'monotonic', 'strictascend', ...
               'strictdescend', 'strictmonotonic'};
      fcn = @(x) (ischar (x) && ismember (x, valid)) || iscellstr (x);
      cid = cellfun (fcn, varargin);
      if (any (cid))
        direction = lower (cellstr (varargin{cid}));

        ## Check for valid type of directions in cellstring
        if (! all (cellfun (@(x) ismember (x, valid), direction)))
          error ("duration.issortedrows: invalid DIRECTION value.");
        endif

        ## Handle non-strict modes first
        if (all (cellfun (@(x) ismember (x, {'ascend', 'descend'}), direction)))
          TF = isequaln (this, sortrows (this, varargin{:}));
          return;
        endif
        simple_types = {'ascend', 'descend', 'monotonic'};
        if (all (cellfun (@(x) ismember (x, simple_types), direction)))
          idx = strcmp (direction, 'monotonic');
          direction{idx} = 'ascend';
          varargin{cid} = direction;
          TF = isequaln (this, sortrows (this, varargin{:}));
          if (TF)
            return;
          endif
          direction{idx} = 'descend';
          varargin{cid} = direction;
          TF = isequaln (this, sortrows (this, varargin{:}));
          return;
        endif

        ## Handle strict modes.  Determine the sort-key column order and the
        ## sort position holding the first strict direction, then derive the
        ## prefix (the columns sorted up to and including that position, on
        ## which the duplicate test operates) and the binding strict column
        ## (the single column on which the missing test operates).  A missing
        ## value in the binding column, or two consecutive sorted rows equal
        ## across the prefix columns, rules out a strict ordering; strict
        ## positions after the first are subsumed by it.
        strict_types = {'strictascend', 'strictdescend', 'strictmonotonic'};
        sflag = cellfun (@(x) ismember (x, strict_types), direction);

        nc = size (this, 2);
        nummask = cellfun (@isnumeric, varargin);
        if (any (nummask))
          col = varargin{nummask};
          ocols = abs (col(:)');
          if (isscalar (direction))
            sflag = repmat (sflag, 1, numel (col));
          elseif (numel (direction) != numel (col))
            error ("categorical.issortedrows: COL and DIRECTION mismatch.");
          endif
        else
          ocols = 1:nc;
          if (isscalar (direction))
            sflag = repmat (sflag, 1, nc);
          endif
        endif
        K = find (sflag, 1);
        prefix = ocols(1:K);
        bindcol = ocols(K);

        ## A missing value in the binding strict column rules out strictness.
        if (any (this.isMissing(:,bindcol), 'all'))
          TF = false;
          return;
        endif

        ## Replace strict modes with their plain counterparts for the sort.
        direction = strrep (direction, 'strict', '');
        varargin{cid} = direction;

        if (all (cellfun (@(x) ismember (x, {'ascend', 'descend'}), direction)))
          sorted = sortrows (this, varargin{:});
          ## No two consecutive rows may tie across the prefix columns.
          tmpcol = sorted.code(:, prefix);
          if (any (all (diff (tmpcol, 1, 1) == 0, 2)))
            TF = false;
            return;
          endif
          TF = isequaln (this, sorted);

        else  # a 'monotonic' position also exists
          idx = strcmp (direction, 'monotonic');
          direction(idx) = {'ascend'};
          varargin{cid} = direction;
          sorted = sortrows (this, varargin{:});
          tmpcol = sorted.code(:, prefix);
          if (any (all (diff (tmpcol, 1, 1) == 0, 2)))
            TF = false;
            return;
          endif
          TF = isequaln (this, sorted);
          if (TF)
            return;
          endif
          direction(idx) = {'descend'};
          varargin{cid} = direction;
          sorted = sortrows (this, varargin{:});
          tmpcol = sorted.code(:, prefix);
          if (any (all (diff (tmpcol, 1, 1) == 0, 2)))
            TF = false;
            return;
          endif
          TF = isequaln (this, sorted);
        endif
      else
        ## No DIRECTION input argument
        TF = isequaln (this, sortrows (this, varargin{:}));
      endif
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {categorical} {@var{out} =} isundefined (@var{C})
    ##
    ## Test for undefined elements in categorical array.
    ##
    ## @code{@var{TF} = isundefined (@var{C})} returns a logical array @var{TF}
    ## of the same size as @var{C} containing @qcode{true} for each
    ## corresponding element of @var{C} that does not have a value from one of
    ## the categories in @var{C} and @qcode{false} otherwise.
    ## @qcode{<undefined>} is the equivalent of @qcode{NaN} in numeric arrays.
    ##
    ## @end deftypefn
    function TF = isundefined (this)
      TF = this.isMissing;
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {categorical} {@var{TF} =} isvector (@var{C})
    ##
    ## Return true if categorical array is a vector.
    ##
    ## @code{@var{TF} = isvector (@var{C})} returns a logical scalar @var{TF},
    ## which is @qcode{true} if the categorical array @var{C} is a vector and
    ## @qcode{false} otherwise.  A vector is a 2-D array for which one of the
    ## dimensions is equal to 1 (either @math{1*N} or @math{N*1}).  By
    ## definition, a scalar is also a vector.
    ##
    ## @end deftypefn
    function TF = isvector (this)
      TF = isvector (this.code);
    endfunction

  endmethods

################################################################################
##                        ** Category Operations **                           ##
################################################################################
##                             Available Methods                              ##
##                                                                            ##
## 'addcats'          'mergecats'        'removecats'       'renamecats'      ##
## 'reordercats'      'setcats'          'times'                              ##
##                                                                            ##
################################################################################

  methods (Access = public)

    ## -*- texinfo -*-
    ## @deftypefn  {categorical} {@var{B} =} addcats (@var{A}, @var{newcats})
    ## @deftypefnx {categorical} {@var{B} =} addcats (@dots{}, @qcode{'After'}, @var{catname})
    ## @deftypefnx {categorical} {@var{B} =} addcats (@dots{}, @qcode{'Before'}, @var{catname})
    ##
    ## Add categories to categorical array.
    ##
    ## @code{@var{B} = addcats (@var{A}, @var{newcats})} appends new categories
    ## specified in @var{newcats} to the categorical array @var{A} at the end of
    ## any existing categories.  The output categorical array @var{B} does not
    ## contain elements that belong to the newly added categories.
    ##
    ## @code{@var{B} = addcats (@dots{}, @qcode{'After'}, @var{catname})} adds
    ## the categories after the existing category specified by @var{catname}.
    ##
    ## @code{@var{B} = addcats (@dots{}, @qcode{'Before'}, @var{catname})} adds
    ## the categories before the existing category specified by @var{catname}.
    ##
    ## @var{catname} must be either a character vector, a cellstr scalar or a
    ## string scalar.  @var{newcats} can be a string array, a cell array of
    ## character vectors, or any type of array that can be converted to a cell
    ## array of character vectors with the @code{cellstr} function, as long as
    ## it does not contain any duplicate names or references existing category
    ## in @var{A}.
    ##
    ## When adding a single category, @var{newcats} can also be specified as a
    ## character vector.
    ##
    ## @end deftypefn
    function B = addcats (A, newcats, varargin)

      ## Check input arguments
      if (nargin < 2)
        error ("categorical.addcats: too few input arguments.");
      elseif (isempty (newcats))
        error ("categorical.addcats: NEWCATS cannot be empty.");
      elseif (isnumeric (newcats) || islogical (newcats))
        error ("categorical.addcats: NEWCATS cannot be numeric or logical.");
      endif

      ## Convert to cellstring
      if (! iscellstr (newcats))
        try
          newcats = cellstr (newcats);
        catch
          error (strcat ("categorical.addcats: NEWCATS cannot be", ...
                         " converted to cellstr."));
        end_try_catch
      endif

      ## Force to column vector
      newcats = newcats(:);

      ## New catnames must be unique and non-existing
      if (numel (newcats) != numel (unique (newcats)))
        error ("categorical.addcats: duplicate category names in NEWCATS.");
      endif
      TF = ismember (newcats, A.cats);
      if (any (TF, 'all'))
        error ("categorical.addcats: new category names already present.");
      endif

      ## Parse optional Name-Value paired arguments
      optNames = {'After', 'Before'};
      dfValues = {[], []};
      [After, Before] = parsePairedArguments (optNames, dfValues, varargin(:));

      ## Check optional Name-Value paired arguments
      if (! isempty (After) && ! isempty (Before))
        error (strcat ("categorical.addcats: cannot specify both", ...
                       " 'After' and 'Before'."));
      endif

      ## Add categories
      if (! isempty (After))
        maxcode = numel (A.cats);
        idxcode = find (strcmp (After, A.cats));
        if (isempty (idxcode))
          error (strcat ("categorical.addcats: 'After' indexes a", ...
                         " non-existing category."));
        elseif (idxcode == maxcode)
          B = A;
          B.cats = [B.cats; newcats];
        else
          B = A;
          B.cats = [B.cats(1:idxcode); newcats; B.cats(idxcode+1:end)];
          idx_add = idxcode+1:maxcode;
          n_cats = numel (newcats);
          for i = numel (idx_add):-1:1
            B.code(B.code == idx_add(i)) += n_cats;
          endfor
        endif
      elseif (! isempty (Before))
        maxcode = numel (A.cats);
        idxcode = find (strcmp (Before, A.cats));
        if (isempty (idxcode))
          error (strcat ("categorical.addcats: 'Before' indexes a", ...
                         " non-existing category."));
        elseif (idxcode == 1)
          B = A;
          B.cats = [newcats; B.cats];
          B.code(! B.isMissing) += numel (newcats);
        else
          B = A;
          B.cats = [B.cats(1:idxcode-1); newcats; B.cats(idxcode:end)];
          idx_add = idxcode:maxcode;
          n_cats = numel (newcats);
          for i = numel (idx_add):-1:1
            B.code(B.code == idx_add(i)) += n_cats;
          endfor
        endif
      else
        B = A;
        B.cats = [B.cats; newcats];
      endif
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn  {categorical} {@var{B} =} mergecats (@var{A}, @var{oldcats})
    ## @deftypefnx {categorical} {@var{B} =} mergecats (@var{A}, @var{oldcats}, @var{newcat})
    ##
    ## Merge categories in categorical array.
    ##
    ## @code{@var{B} = mergecats (@var{A}, @var{oldcats})} merges two or more
    ## categories specified by @var{oldcats} into a single category with the
    ## same name as @qcode{@var{oldcats}(1)}.  In case of ordinal categorical
    ## arrays, the categories listed in @var{oldcats} must be in consecutive
    ## order.  All elements of @var{A} corresponding to the categories listed in
    ## @var{oldcats} are re-indexed to correspond to @qcode{@var{oldcats}(1)} in
    ## @var{B}.
    ##
    ## @code{@var{B} = mergecats (@var{A}, @var{oldcats}, @var{newcat})} merges
    ## the categories listed in @var{oldcats} into a single new category named
    ## as specified by @var{newcat}.
    ##
    ## @var{newcat} must be either a character vector, a cellstr scalar or a
    ## string scalar.  @var{oldcats} can be string array, a cell array of
    ## character vectors, or any type of array that can be converted to a cell
    ## array of character vectors with the @code{cellstr} function.  Any names
    ## in @var{oldcats} that do not reference an existing category are ignored.
    ##
    ## @end deftypefn
    function B = mergecats (A, oldcats, varargin)

      ## Check input arguments
      if (nargin < 2)
        error ("categorical.mergecats: too few input arguments.");
      elseif (isempty (oldcats))
        error ("categorical.mergecats: OLDCATS cannot be empty.");
      elseif (isnumeric (oldcats) || islogical (oldcats))
        error ("categorical.mergecats: OLDCATS cannot be numeric or logical.");
      elseif (ischar (oldcats))
        error ("categorical.mergecats: OLDCATS cannot be a character vector.");
      endif

      ## Convert to cellstring
      if (! iscellstr (oldcats))
        try
          oldcats = cellstr (oldcats);
        catch
          error (strcat ("categorical.mergecats: OLDCATS cannot be", ...
                         " converted to cellstr."));
        end_try_catch
      endif

      ## Force to column vector
      oldcats = oldcats(:);

      ## Check for optional third argument
      if (nargin < 3)
        newcat = oldcats{1};
      else
        newcat = varargin{1};
        if (isstring (newcat) && isscalar (newcat))
          newcat = cellstr (newcat);
        elseif (ischar (newcat) && (isrow (newcat) || isempty (newcat)))
          newcat = cellstr (newcat);
        elseif (! (iscellstr (newcat) && isscalar (newcat)))
          error (strcat ("categorical.mergecats: NEWCAT must be either", ...
                         " a character vector, a cellstring scalar, or", ...
                         " a string scalar."));
        endif
        newcat = cellstr (varargin{1});
        if (cellfun ('isempty', newcat))
          error ("categorical.mergecats: NEWCAT cannot be empty.");
        endif
      endif

      ## Keep old cat names that reference existing categories, ignore the rest
      [TF, index] = ismember (oldcats, A.cats);
      if (! any (TF, 'all'))
        B = A;
        return;
      endif
      index(! TF) = [];

      ## Only consecutive categories can be merged in ordinal arrays
      if (A.isOrdinal && any (diff (sort (index)) != 1))
        error (strcat ("categorical.mergecats: only consecutive categories", ...
                       " can be merged in ordinal categorical arrays."));
      endif

      ## Merge categories
      TF = ismember (A.code, index);
      [Findex, Fidx] = min (index);
      index(Fidx) = [];
      B = A;
      B.code(TF) = Findex;
      B.cats(Findex) = newcat;
      B.cats(index) = [];

      ## Fix codes due to removed categories
      index = sort (index, 'descend');
      for idx = index(:)'
        B.code(B.code > idx) -= 1;
      endfor
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn  {categorical} {@var{B} =} removecats (@var{A})
    ## @deftypefnx {categorical} {@var{B} =} removecats (@var{A}, @var{oldcats})
    ##
    ## Remove categories from categorical array.
    ##
    ## @code{@var{B} = removecats (@var{A})} removes all unused categories from
    ## categorical array @var{A}.  The output categorical array @var{B} has the
    ## same size and values as @var{A}, but potentially fewer categories.
    ##
    ## @code{@var{B} = removecats (@var{A}, @var{oldcats})} removes the
    ## categories specified by @var{oldcats}.  The elements of @var{B} that
    ## correspond to the removed categories are undefined.
    ##
    ## @var{oldcats} can be a string array, a cell array of character vectors,
    ## or any type of array that can be converted to a cell array of character
    ## vectors with the @code{cellstr} function.  Any names in @var{oldcats}
    ## that do not reference an existing category are ignored.
    ##
    ## When removing a single category, @var{oldcats} can also be specified as a
    ## character vector.
    ##
    ## @end deftypefn
    function B = removecats (A, varargin)

      ## Remove unused categories
      if (nargin == 1)
        usedcodes = __unique__ (A.code);
        usedcodes(usedcodes == 0) = [];
        remcodes = setdiff (1:numel (A.cats), usedcodes);
        B = A;
        B.cats(remcodes) = [];
        remcodes = sort (remcodes, 'descend');
        for code = remcodes(:)'
          B.code(B.code > code) -= 1;
        endfor

      else
        oldcats = varargin{1};
        ## Some input validation
        if (isempty (oldcats))
          ## Handle MATLAB compatibility here
          if (iscell (oldcats))
            B = A;
            return;
          endif
          error ("categorical.removecats: OLDCATS cannot be empty.");
        elseif (isnumeric (oldcats) || islogical (oldcats))
          error (strcat ("categorical.removecats: OLDCATS cannot be", ...
                         " numeric or logical."));
        endif
        ## Convert to cellstring
        if (! iscellstr (oldcats))
          try
            oldcats = cellstr (oldcats);
          catch
            error (strcat ("categorical.removecats: OLDCATS", ...
                           " cannot be converted to cellstr."));
          end_try_catch
        endif

        ## Keep old cat names that reference existing categories, ignore the
        ## rest
        [TF, remcodes] = ismember (oldcats, A.cats);
        if (! any (TF, 'all'))
          B = A;
          return;
        endif
        remcodes(! TF) = [];
        [TF] = ismember (A.code, remcodes);
        B = A;
        B.code(TF) = 0;
        B.cats(remcodes) = [];
        B.isMissing = B.isMissing | TF;
        remcodes = sort (remcodes, 'descend');
        for code = remcodes(:)'
          B.code(B.code > code) -= 1;
        endfor
      endif
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn  {categorical} {@var{B} =} renamecats (@var{A}, @var{newnames})
    ## @deftypefnx {categorical} {@var{B} =} renamecats (@var{A}, @var{oldnames}, @var{newnames})
    ##
    ## Rename categories in categorical array.
    ##
    ## @code{@var{B} = renamecats (@var{A}, @var{newnames})} renames all the
    ## categories in @var{A}, without changing any of its values, with the names
    ## specified in @var{newnames}.  @var{newnames} can be specified as a string
    ## array, a cell array of character vectors, or any type of array that can
    ## be converted to a cell array of character vectors with the @code{cellstr}
    ## function, as long as it has the same number of elements as the categories
    ## in @var{A}.
    ##
    ## @code{@var{B} = renamecats (@var{A}, @var{oldnames}, @var{newnames})}
    ## renames the categories of @var{A} specified in @var{oldnames} with the
    ## names specified in @var{newnames}.  Both @var{oldnames} and
    ## @var{newnames} can be a string array, a cell array of character vectors,
    ## or any type of array that can be converted to a cell array of character
    ## vectors with the @code{cellstr} function, as long as they have the same
    ## number of elements.  @var{oldnames} must specify a subset of existing
    ## categories in @var{A}.
    ##
    ## When renaming a single category, both @var{oldnames} and @var{newnames}
    ## can also be specified as character vectors.
    ##
    ## @end deftypefn
    function B = renamecats (A, varargin)
      ## Check input arguments
      if (nargin < 2)
        error ("categorical.renamecats: too few input arguments.");
      endif

      ## Process 2 input arguments
      if (nargin == 2)
        oldnames = A.cats;
        newnames = varargin{1};

        ## Some input validation
        if (isempty (newnames))
          ## Handle MATLAB compatibility here
          if (iscell (newnames))
            B = A;
            return;
          endif
          error ("categorical.renamecats: NEWNAMES cannot be empty.");
        elseif (isnumeric (newnames) || islogical (newnames))
          error (strcat ("categorical.renamecats: NEWNAMES cannot be", ...
                         " numeric or logical."));
        endif

        ## Convert to cellstring
        if (! iscellstr (newnames))
          try
            newnames = cellstr (newnames);
          catch
            error (strcat ("categorical.renamecats: NEWNAMES", ...
                           " cannot be converted to cellstr."));
          end_try_catch
        endif

        ## Make sure categories match
        if (numel (oldnames) != numel (newnames))
          error (strcat ("categorical.renamecats: NEWNAMES must equal the", ...
                         " number of existing categories in input array."));
        endif

      ## Process 3 input arguments
      else
        oldnames = varargin{1};
        newnames = varargin{2};

        ## OLDNAMES input validation
        if (isempty (oldnames) && ! iscell (oldnames))
          error ("categorical.renamecats: OLDNAMES cannot be empty.");
        elseif (isnumeric (oldnames) || islogical (oldnames))
          error (strcat ("categorical.renamecats: OLDNAMES cannot be", ...
                         " numeric or logical."));
        endif
        ## Convert to cellstring
        if (! iscellstr (oldnames))
          try
            oldnames = cellstr (oldnames);
          catch
            error (strcat ("categorical.renamecats: OLDNAMES", ...
                           " cannot be converted to cellstr."));
          end_try_catch
        endif

        ## NEWNAMES input validation
        if (isempty (newnames) && ! iscell (newnames))
          error ("categorical.renamecats: NEWNAMES cannot be empty.");
        elseif (isnumeric (newnames) || islogical (newnames))
          error (strcat ("categorical.renamecats: NEWNAMES cannot be", ...
                         " numeric or logical."));
        endif
        ## Convert to cellstring
        if (! iscellstr (newnames))
          try
            newnames = cellstr (newnames);
          catch
            error (strcat ("categorical.renamecats: NEWNAMES", ...
                           " cannot be converted to cellstr."));
          end_try_catch
        endif

        ## Make sure category names match
        if (numel (oldnames) != numel (newnames))
          error (strcat ("categorical.renamecats: OLDNAMES and NEWNAMES", ...
                         " must have the same number of elements."));
        endif
      endif

      ## Find and rename existing categories
      [TF, index] = ismember (oldnames, A.cats);
      if (! all (TF))
        error (strcat ("categorical.renamecats: OLDNAMES must be", ...
                       " a subset of existing categories."));
      endif
      B = A;
      B.cats(index) = newnames;
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn  {categorical} {@var{B} =} reordercats (@var{A})
    ## @deftypefnx {categorical} {@var{B} =} reordercats (@var{A}, @var{neworder})
    ##
    ## Reorder categories in categorical array.
    ##
    ## @code{@var{B} = reordercats (@var{A})} reorders the categories of @var{A}
    ## in alphanumeric order.
    ##
    ## @code{@var{B} = reordercats (@var{A}, @var{neworder})} reorders the
    ## categories of @var{A} according to the order specified by @var{neworder},
    ## which must define a permutation of @code{categories (@var{A})}.
    ##
    ## @var{neworder} can be a numeric vector, a string array, a cell array of
    ## character vectors, or any type of array that can be converted to a cell
    ## array of character vectors with the @code{cellstr} function as long as it
    ## indexes all existing categories in @var{A}.
    ##
    ## @end deftypefn
    function B = reordercats (A, varargin)
      ## Get NEWORDER
      if (nargin == 1)
        neworder = sort (A.cats);
      else
        neworder = varargin{1};

        ## NEWORDER input validation
        if (isnumeric (neworder))
          catidx = 1:numel (A.cats);
          if (! (isvector (neworder) && neworder > 0 && ...
                 fix (neworder) == neworder))
            error (strcat ("categorical.reordercats: NEWORDER numeric", ...
                           " input must be a vector of positive integers."));
          endif
          if (! isequal (sort (neworder)(:), catidx(:)))
            error (strcat ("categorical.reordercats: NEWORDER numeric", ...
                           " input must index a permutation of existing", ...
                           " categories."));
          endif
          neworder = A.cats(neworder);
        elseif (isempty (neworder))
          error ("categorical.reordercats: NEWORDER cannot be empty.");
        elseif (islogical (neworder))
          error ("categorical.reordercats: NEWORDER cannot be logical.");
        elseif (ischar (neworder))
          error (strcat ("categorical.reordercats: NEWORDER cannot be", ...
                         " a character vector."));
        endif

        ## Convert to cellstring
        if (! iscellstr (neworder))
          try
            neworder = cellstr (varargin{1});
          catch
            error (strcat ("categorical.reordercats: NEWORDER", ...
                           " cannot be converted to cellstr."));
          end_try_catch
        endif

        ## Make sure categories match
        if (! all (ismember (neworder, A.cats)))
          error (strcat ("categorical.reordercats: NEWORDER must contain", ...
                         " the same set with the existing categories."));
        endif
      endif

      ## Reorder
      [~, newidx] = ismember (A.cats, neworder);
      B = A;
      B.code(! B.isMissing) = newidx(A.code(! A.isMissing));
      B.cats = neworder(:);
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {categorical} {@var{B} =} setcats (@var{A}, @var{newcats})
    ##
    ## Set categories in categorical array.
    ##
    ## @code{@var{B} = setcats (@var{A}, @var{newcats})} sets categories in the
    ## categorical array @var{B} according to the elements of the input array
    ## @var{A} and the categories specified by @var{newcats} according to the
    ## following rules:
    ##
    ## @itemize
    ## @item Any element of @var{A} that corresponds to a category listed in
    ## @var{newcats} is copied to @var{B} with the same categorical value.
    ## @item Any categories of @var{A} not listed in @var{newcats} are not
    ## copied to @var{B} and the corresponding elements of @var{B} are
    ## undefined.
    ## @item New categories listed in @var{newcats} that are not present in
    ## @var{A} are added in @var{B}, but without any elements equal to these
    ## new categories.
    ## @end itemize
    ##
    ## @var{newcats} can be a string array, a cell array of character vectors
    ## or any type of array that can be converted to a cell array of character
    ## vectors with the @code{cellstr} function.  When setting a single new
    ## category, @var{newcats} can also be specified as a character vector.
    ##
    ## @end deftypefn
    function B = setcats (A, newcats)
      ## Check input arguments
      if (nargin < 2)
        error ("categorical.setcats: too few input arguments.");
      endif

      ## NEWCATS input validation
      if (isempty (newcats))
        ## Empty cell removes all categories
        if (iscell (newcats))
          B = A;
          B.cats = {};
          sz = size (A);
          B.code = uint16 (zeros (sz));
          B.isMissing = true (sz);
          return;
        endif
        error ("categorical.setcats: NEWCATS cannot be empty.");
      elseif (isnumeric (newcats) || islogical (newcats))
        error ("categorical.setcats: NEWCATS cannot be numeric or logical.");
      endif

      ## Convert to cellstring
      if (! iscellstr (newcats))
        try
          newcats = cellstr (newcats);
        catch
          error (strcat ("categorical.setcats: NEWCATS cannot be", ...
                         " converted to cellstr."));
        end_try_catch
      endif

      ## Find existing categories
      [TF, index] = ismember (A.cats, newcats);
      ## Remove and reorder categories
      if (all (TF, 'all'))
        B = A;
      else
        B = removecats (A, A.cats(! TF));
      endif
      [TF, index] = ismember (newcats, B.cats);
      index(! TF) = [];
      B = reordercats (B, B.cats(index));
      [TF, index] = ismember (newcats, B.cats);
      index(! TF) = [];
      ## For each new category added before previously existing categories
      ## we need to increase the code indexing respectively
      for i = 1:numel (TF)
        if (! TF(i))
          B.code(B.code >= i) += 1;
        endif
      endfor
      B.cats = newcats(:);
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {categorical} {@var{C} =} times (@var{A}, @var{B})
    ##
    ## Combine categorical arrays.
    ##
    ## @code{@var{C} = times (@var{A}, @var{B})} is the equivalent of the syntax
    ## @code{@var{C} = @var{A} .* @var{B}} and returns a categorical array whose
    ## categories are the Cartesian product of the categories in @var{A} and
    ## @var{B} and each element is indexed to a new category which is the
    ## combination of the categories of the corresponding elements in @var{A}
    ## and @var{B}.
    ##
    ## @var{A} and @var{B} must be of common size or scalar categorical arrays.
    ##
    ## @end deftypefn
    function C = times (A, B)
      ## Check input arguments
      if (nargin < 2)
        error ("categorical.times: too few input arguments.");
      endif
      if (! isa (A, 'categorical') || ! isa (B, 'categorical'))
        error ("categorical.times: A and B must be categorical arrays.");
      endif
      newcats = {};
      n_Bcats = numel (B.cats);
      for i = 1:numel (A.cats)
        for j = 1:n_Bcats
          newcats = [newcats; strjoin({A.cats{i}, B.cats{j}})];
        endfor
      endfor
      C = addcats (categorical, newcats);
      C.code = A.code .* n_Bcats - (n_Bcats - B.code);
      C.isMissing = A.isMissing | B.isMissing;
    endfunction

  endmethods

################################################################################
##                        ** Relational Operations **                         ##
################################################################################
##                             Available Methods                              ##
##                                                                            ##
## 'eq'               'ge'               'gt'               'le'              ##
## 'lt'               'ne'                                                    ##
##                                                                            ##
################################################################################

  methods (Access = public)

    ## -*- texinfo -*-
    ## @deftypefn {categorical} {@var{TF} =} eq (@var{A}, @var{B})
    ##
    ## Equality for categorical arrays.
    ##
    ## @code{@var{TF} = eq (@var{A}, @var{B})} is the equivalent of the syntax
    ## @code{@var{TF} = @var{A} == @var{B}} and returns a logical array of the
    ## same size as the largest input with its elements set to @qcode{true}
    ## where the corresponding elements of @var{A} and @var{B} are equal and set
    ## to @qcode{false} where they are not.  @var{A} and @var{B} must be size
    ## compatible, which translates to they can be the same size, one can be
    ## scalar, or for every dimension, their dimension sizes must be equal or
    ## one of them must be 1.
    ##
    ## If categorical arrays @var{A} and @var{B} are ordinal, they must have
    ## the same set and ordering of categories.  If neither are ordinal, the
    ## category names of each pair of elements are compared.  Hence, they do
    ## not need to have the same set of categories.
    ##
    ## One of the input arguments can also be a character vector, a cellstr
    ## scalar or a string scalar as long as the other is a categorical array.
    ## In this case, a logical array of the same size as the categorical array
    ## is returned in which every element is tested for equality by comparing
    ## its category with that specified by the string argument.
    ##
    ## Undefined elements always return @qcode{false}, since they are not
    ## comparable to any other categorical values including other undefined
    ## elements.
    ##
    ## @end deftypefn
    function TF = eq (A, B)
      if (iscellstr (B) || isa (B, 'string') || ischar (B))
        B = cellstr (B);
        if (! isscalar (B))
          error (strcat ("categorical.eq: any text representation must", ...
                         " specify a single categorical value."));
        endif
        TF = strcmp (B, cellstr (A));
      elseif (iscellstr (A) || isa (A, 'string') || ischar (A))
        A = cellstr (A);
        if (! isscalar (A))
          error (strcat ("categorical.eq: any text representation must", ...
                         " specify a single categorical value."));
        endif
        TF = strcmp (A, cellstr (B));
      elseif (iscategorical (A) && iscategorical (B))
        if (A.isOrdinal && B.isOrdinal)
          ## Check that both categorical arrays have the same categories
          ## and they are in the same order
          cats = cellfun (@(x) categories (x), {A, B}, 'UniformOutput', false);
          if (! isequal (cats{:}))
            error (strcat ("categorical.eq: comparison between ordinal", ...
                           " arrays requires that both have the same", ...
                           " categories, which must be ordered in the", ...
                           " same way."));
          endif
          TF = double (A) == double (B);
        elseif (A.isOrdinal || B.isOrdinal)
          error (strcat ("categorical.eq: cannot compare a categorical", ...
                         " array that is ordinal with one that is not."));
        else
          [A, B, err] = csize_expand (A, B);
          if (err)
            error ("categorical.eq: arrays have incompatible sizes.");
          endif
          TF = strcmp (cellstr (A), cellstr (B));
        endif
      else
        error (strcat ("categorical.eq: comparison is not defined between", ...
                       " '%s' and '%s' arrays."), class (A), class (B));
      endif
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {categorical} {@var{TF} =} ge (@var{A}, @var{B})
    ##
    ## Greater than or equal to for ordinal categorical arrays.
    ##
    ## @code{@var{TF} = ge (@var{A}, @var{B})} is the equivalent of the syntax
    ## @code{@var{TF} = @var{A} >= @var{B}} and returns a logical array of the
    ## same size as the largest input with its elements set to @qcode{true}
    ## where the corresponding elements of @var{A} are greater than or equal to
    ## @var{B} and set to @qcode{false} where they are not.  @var{A} and @var{B}
    ## must be size compatible, which translates to they can be the same size,
    ## one can be scalar, or for every dimension, their dimension sizes must be
    ## equal or one of them must be 1.
    ##
    ## If categorical arrays @var{A} and @var{B} are both ordinal, they must
    ## have the same set and ordering of categories.  Unordered categorical
    ## arrays cannot be compared for greater than or equal to inequality.
    ##
    ## One of the input arguments can also be a character vector, a cellstr
    ## scalar or a string scalar as long as the other is a categorical array.
    ## In this case, a logical array of the same size as the categorical array
    ## is returned in which every element is tested for greater than or equal to
    ## inequality by comparing its category with that specified by the string
    ## argument.
    ##
    ## Undefined elements always return @qcode{false}, since they are not
    ## comparable to any other categorical values including other undefined
    ## elements.
    ##
    ## @end deftypefn
    function TF = ge (A, B)
      if (iscellstr (B) || isa (B, 'string') || ischar (B))
        if (! A.isOrdinal)
          error ("categorical.ge: categorical array is not ordinal.");
        endif
        B = cellstr (B);
        if (! isscalar (B))
          error (strcat ("categorical.ge: any text representation must", ...
                         " specify a single categorical value."));
        endif
        code = find (ismember (categories (A), B));
        if (isempty (code))
          error ("categorical.ge: category does not exist in array.");
        endif
        TF = double (A) >= code;
      elseif (iscellstr (A) || isa (A, 'string') || ischar (A))
        if (! B.isOrdinal)
          error ("categorical.ge: categorical array is not ordinal.");
        endif
        A = cellstr (A);
        if (! isscalar (A))
          error (strcat ("categorical.ge: any text representation must", ...
                         " specify a single categorical value."));
        endif
        code = find (ismember (categories (B), A));
        if (isempty (code))
          error ("categorical.ge: category does not exist in array.");
        endif
        TF = code >= double (B);
      elseif (! iscategorical (A) || ! iscategorical (B))
        error (strcat ("categorical.ge: relational comparison is not", ...
                       " defined between '%s' and '%s' arrays."), ...
               class (A), class (B));
      elseif (! A.isOrdinal || ! B.isOrdinal)
        error (strcat ("categorical.ge: relational comparison is not", ...
                       " allowed between non-ordinal categorical arrays."));
      else
        TF = double (A) >= double (B);
      endif
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {categorical} {@var{TF} =} gt (@var{A}, @var{B})
    ##
    ## Greater than for ordinal categorical arrays.
    ##
    ## @code{@var{TF} = gt (@var{A}, @var{B})} is the equivalent of the syntax
    ## @code{@var{TF} = @var{A} > @var{B}} and returns a logical array of the
    ## same size as the largest input with its elements set to @qcode{true}
    ## where the corresponding elements of @var{A} are greater than @var{B} and
    ## set to @qcode{false} where they are not.  @var{A} and @var{B} must be
    ## size compatible, which translates to they can be the same size, one can
    ## be scalar, or for every dimension, their dimension sizes must be equal or
    ## one of them must be 1.
    ##
    ## If categorical arrays @var{A} and @var{B} are both ordinal, they must
    ## have the same set and ordering of categories.  Unordered categorical
    ## arrays cannot be compared for greater than inequality.
    ##
    ## One of the input arguments can also be a character vector, a cellstr
    ## scalar or a string scalar as long as the other is a categorical array.
    ## In this case, a logical array of the same size as the categorical array
    ## is returned in which every element is tested for greater than inequality
    ## by comparing its category with that specified by the string argument.
    ##
    ## Undefined elements always return @qcode{false}, since they are not
    ## comparable to any other categorical values including other undefined
    ## elements.
    ##
    ## @end deftypefn
    function TF = gt (A, B)
      if (iscellstr (B) || isa (B, 'string') || ischar (B))
        if (! A.isOrdinal)
          error ("categorical.gt: categorical array is not ordinal.");
        endif
        B = cellstr (B);
        if (! isscalar (B))
          error (strcat ("categorical.gt: any text representation must", ...
                         " specify a single categorical value."));
        endif
        code = find (ismember (categories (A), B));
        if (isempty (code))
          error ("categorical.gt: category does not exist in array.");
        endif
        TF = double (A) > code;
      elseif (iscellstr (A) || isa (A, 'string') || ischar (A))
        if (! B.isOrdinal)
          error ("categorical.gt: categorical array is not ordinal.");
        endif
        A = cellstr (A);
        if (! isscalar (A))
          error (strcat ("categorical.gt: any text representation must", ...
                         " specify a single categorical value."));
        endif
        code = find (ismember (categories (B), A));
        if (isempty (code))
          error ("categorical.gt: category does not exist in array.");
        endif
        TF = code > double (B);
      elseif (! iscategorical (A) || ! iscategorical (B))
        error (strcat ("categorical.gt: relational comparison is not", ...
                       " defined between '%s' and '%s' arrays."), ...
               class (A), class (B));
      elseif (! A.isOrdinal || ! B.isOrdinal)
        error (strcat ("categorical.gt: relational comparison is not", ...
                       " allowed between non-ordinal categorical arrays."));
      else
        TF = double (A) > double (B);
      endif
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {categorical} {@var{TF} =} le (@var{A}, @var{B})
    ##
    ## Less than or equal to for ordinal categorical arrays.
    ##
    ## @code{@var{TF} = le (@var{A}, @var{B})} is the equivalent of the syntax
    ## @code{@var{TF} = @var{A} <= @var{B}} and returns a logical array of the
    ## same size as the largest input with its elements set to @qcode{true}
    ## where the corresponding elements of @var{A} are less than or equal to
    ## @var{B} and set to @qcode{false} where they are not.  @var{A} and @var{B}
    ## must be size compatible, which translates to they can be the same size,
    ## one can be scalar, or for every dimension, their dimension sizes must be
    ## equal or one of them must be 1.
    ##
    ## If categorical arrays @var{A} and @var{B} are both ordinal, they must
    ## have the same set and ordering of categories.  Unordered categorical
    ## arrays cannot be compared for less than or equal to inequality.
    ##
    ## One of the input arguments can also be a character vector, a cellstr
    ## scalar or a string scalar as long as the other is a categorical array.
    ## In this case, a logical array of the same size as the categorical array
    ## is returned in which every element is tested for less than or equal to
    ## inequality by comparing its category with that specified by the string
    ## argument.
    ##
    ## Undefined elements always return @qcode{false}, since they are not
    ## comparable to any other categorical values including other undefined
    ## elements.
    ##
    ## @end deftypefn
    function TF = le (A, B)
      if (iscellstr (B) || isa (B, 'string') || ischar (B))
        if (! A.isOrdinal)
          error ("categorical.le: categorical array is not ordinal.");
        endif
        B = cellstr (B);
        if (! isscalar (B))
          error (strcat ("categorical.le: any text representation must", ...
                         " specify a single categorical value."));
        endif
        code = find (ismember (categories (A), B));
        if (isempty (code))
          error ("categorical.le: category does not exist in array.");
        endif
        TF = double (A) <= code;
      elseif (iscellstr (A) || isa (A, 'string') || ischar (A))
        if (! B.isOrdinal)
          error ("categorical.le: categorical array is not ordinal.");
        endif
        A = cellstr (A);
        if (! isscalar (A))
          error (strcat ("categorical.le: any text representation must", ...
                         " specify a single categorical value."));
        endif
        code = find (ismember (categories (B), A));
        if (isempty (code))
          error ("categorical.le: category does not exist in array.");
        endif
        TF = code <= double (B);
      elseif (! iscategorical (A) || ! iscategorical (B))
        error (strcat ("categorical.le: relational comparison is not", ...
                       " defined between '%s' and '%s' arrays."), ...
               class (A), class (B));
      elseif (! A.isOrdinal || ! B.isOrdinal)
        error (strcat ("categorical.le: relational comparison is not", ...
                       " allowed between non-ordinal categorical arrays."));
      else
        TF = double (A) <= double (B);
      endif
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {categorical} {@var{TF} =} lt (@var{A}, @var{B})
    ##
    ## Less than for ordinal categorical arrays.
    ##
    ## @code{@var{TF} = lt (@var{A}, @var{B})} is the equivalent of the syntax
    ## @code{@var{TF} = @var{A} < @var{B}} and returns a logical array of the
    ## same size as the largest input with its elements set to @qcode{true}
    ## where the corresponding elements of @var{A} are less than @var{B} and
    ## set to @qcode{false} where they are not.  @var{A} and @var{B} must be
    ## size compatible, which translates to they can be the same size, one can
    ## be scalar, or for every dimension, their dimension sizes must be equal or
    ## one of them must be 1.
    ##
    ## If categorical arrays @var{A} and @var{B} are both ordinal, they must
    ## have the same set and ordering of categories.  Unordered categorical
    ## arrays cannot be compared for less than inequality.
    ##
    ## One of the input arguments can also be a character vector, a cellstr
    ## scalar or a string scalar as long as the other is a categorical array.
    ## In this case, a logical array of the same size as the categorical array
    ## is returned in which every element is tested for less than inequality
    ## by comparing its category with that specified by the string argument.
    ##
    ## Undefined elements always return @qcode{false}, since they are not
    ## comparable to any other categorical values including other undefined
    ## elements.
    ##
    ## @end deftypefn
    function TF = lt (A, B)
      if (iscellstr (B) || isa (B, 'string') || ischar (B))
        if (! A.isOrdinal)
          error ("categorical.lt: categorical array is not ordinal.");
        endif
        B = cellstr (B);
        if (! isscalar (B))
          error (strcat ("categorical.lt: any text representation must", ...
                         " specify a single categorical value."));
        endif
        code = find (ismember (categories (A), B));
        if (isempty (code))
          error ("categorical.lt: category does not exist in array.");
        endif
        TF = double (A) < code;
      elseif (iscellstr (A) || isa (A, 'string') || ischar (A))
        if (! B.isOrdinal)
          error ("categorical.lt: categorical array is not ordinal.");
        endif
        A = cellstr (A);
        if (! isscalar (A))
          error (strcat ("categorical.lt: any text representation must", ...
                         " specify a single categorical value."));
        endif
        code = find (ismember (categories (B), A));
        if (isempty (code))
          error ("categorical.lt: category does not exist in array.");
        endif
        TF = code < double (B);
      elseif (! iscategorical (A) || ! iscategorical (B))
        error (strcat ("categorical.lt: relational comparison is not", ...
                       " defined between '%s' and '%s' arrays."), ...
               class (A), class (B));
      elseif (! A.isOrdinal || ! B.isOrdinal)
        error (strcat ("categorical.lt: relational comparison is not", ...
                       " allowed between non-ordinal categorical arrays."));
      else
        TF = double (A) < double (B);
      endif
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {categorical} {@var{TF} =} ne (@var{A}, @var{B})
    ##
    ## Not equal for categorical arrays.
    ##
    ## @code{@var{TF} = ne (@var{A}, @var{B})} is the equivalent of the syntax
    ## @code{@var{TF} = @var{A} != @var{B}} and returns a logical array of the
    ## same size as the largest input with its elements set to @qcode{true}
    ## where the corresponding elements of @var{A} and @var{B} are not equal and
    ## set to @qcode{false} where they are equal.  @var{A} and @var{B} must be
    ## size compatible, which translates to they can be the same size, one can
    ## be scalar, or for every dimension, their dimension sizes must be equal or
    ## one of them must be 1.
    ##
    ## If categorical arrays @var{A} and @var{B} are ordinal, they must have
    ## the same set and ordering of categories.  If neither are ordinal, the
    ## category names of each pair of elements are compared.  Hence, they do
    ## not need to have the same set of categories.
    ##
    ## One of the input arguments can also be a character vector, a cellstr
    ## scalar or a string scalar as long as the other is a categorical array.
    ## In this case, a logical array of the same size as the categorical array
    ## is returned in which every element is tested for inequality by comparing
    ## its category with that specified by the string argument.
    ##
    ## Undefined elements always return @qcode{true}, since they are not
    ## comparable to any other categorical values including other undefined
    ## elements.
    ##
    ## @end deftypefn
    function TF = ne (A, B)
      if (iscellstr (B) || isa (B, 'string') || ischar (B))
        B = cellstr (B);
        if (! isscalar (B))
          error (strcat ("categorical.ne: any text representation must", ...
                         " specify a single categorical value."));
        endif
        TF = ! strcmp (B, cellstr (A));
      elseif (iscellstr (A) || isa (A, 'string') || ischar (A))
        A = cellstr (A);
        if (! isscalar (A))
          error (strcat ("categorical.ne: any text representation must", ...
                         " specify a single categorical value."));
        endif
        TF = ! strcmp (A, cellstr (B));
      elseif (iscategorical (A) && iscategorical (B))
        if (A.isOrdinal && B.isOrdinal)
          ## Check that both categorical arrays have the same categories
          ## and they are in the same order
          cats = cellfun (@(x) categories (x), {A, B}, 'UniformOutput', false);
          if (! isequal (cats{:}))
            error (strcat ("categorical.ne: comparison between ordinal", ...
                           " arrays requires that both have the same", ...
                           " categories, which must be ordered in the", ...
                           " same way."));
          endif
          TF = double (A) != double (B);
        elseif (A.isOrdinal || B.isOrdinal)
          error (strcat ("categorical.ne: cannot compare a categorical", ...
                         " array that is ordinal with one that is not."));
        else
          [A, B, err] = csize_expand (A, B);
          if (err)
            error ("categorical.ne: arrays have incompatible sizes.");
          endif
          TF = ! strcmp (cellstr (A), cellstr (B));
        endif
      else
        error (strcat ("categorical.ne: comparison is not defined between", ...
                       " '%s' and '%s' arrays."), class (A), class (B));
      endif
    endfunction
  endmethods

################################################################################
##                        ** Arithmetic Operations **                         ##
################################################################################
##                             Available Methods                              ##
##                                                                            ##
## 'min'              'max'              'median'           'mode'            ##
## 'histcounts'                                                               ##
##                                                                            ##
################################################################################

  methods (Access = public)

    ## -*- texinfo -*-
    ## @deftypefn  {categorical} {@var{C} =} min (@var{A})
    ## @deftypefnx {categorical} {[@var{C}, @var{index}] =} min (@var{A})
    ## @deftypefnx {categorical} {@var{C} =} min (@var{A}, @qcode{[]}, @var{dim})
    ## @deftypefnx {categorical} {@var{C} =} min (@var{A}, @qcode{[]}, @var{vecdim})
    ## @deftypefnx {categorical} {@var{C} =} min (@var{A}, @qcode{[]}, @qcode{'all'})
    ## @deftypefnx {categorical} {[@var{C}, @var{index}] =} min (@var{A}, @qcode{[]}, @qcode{'linear'})
    ## @deftypefnx {categorical} {[@var{C}, @var{index}] =} min (@var{A}, @qcode{[]}, @dots{}, @qcode{'linear'})
    ## @deftypefnx {categorical} {@var{C} =} min (@var{A}, @var{B})
    ## @deftypefnx {categorical} {[@dots{}] =} min (@dots{}, @var{missingflag})
    ##
    ## Smallest elements in ordinal categorical arrays.
    ##
    ## @code{@var{C} = min (@var{A})} returns the smallest element in ordinal
    ## categorical vector @var{A}.  If @var{A} is a matrix, @code{min (@var{A})}
    ## returns a row vector with the smallest element from each column.  For
    ## multidimensional arrays, @code{min (@var{A})} operates along the first
    ## non-singleton dimension.
    ##
    ## @code{[@var{C}, @var{index}] = min (@var{A})} also returns the indices of
    ## the minimum values in @var{index}, which has the same size as @var{C}.
    ## When the operating dimension contains more than one minimal elements, the
    ## index of the first one is returned.
    ##
    ## @code{@var{C} = min (@var{A}, @qcode{[]}, @var{dim})} operates along the
    ## dimension specified by @var{dim}.
    ##
    ## @code{@var{C} = min (@var{A}, @qcode{[]}, @var{vecdim})} operates on all
    ## the elements contained in the dimensions specified by @var{vecdim}, which
    ## must be a numeric vector of non-repeating positive integers.  Any values
    ## in @var{vecdim} indexing dimensions larger that the actual array @var{A}
    ## are ignored.
    ##
    ## @code{@var{C} = min (@var{A}, @qcode{[]}, @qcode{'all'})} operates on all
    ## dimensions and returns the smallest element in @var{A}.
    ##
    ## @code{[@var{C}, @var{index}] = min (@var{A}, @qcode{[]}, @dots{})} also
    ## returns the first index of the minimum values in @var{index}.  The second
    ## output is only valid when @code{min} operates on a single input array.
    ## Setting the @qcode{'linear'} flag returns the linear index to the
    ## corresponding minimum values in @var{A}.
    ##
    ## @code{@var{C} = min (@var{A}, @var{B})} returns an ordinal categorical
    ## array @var{C} with the smallest elements from @var{A} and @var{B}, which
    ## both must be ordinal categorical arrays of compatible sizes with the same
    ## set and ordering of categories.  Compatible size means that @var{A} and
    ## @var{B} can be the same size, one can be scalar, or for every dimension,
    ## their dimension sizes must be equal or one of them must be 1.
    ##
    ## @code{[@dots{}] = min (@dots{}, @var{missingflag})} specifies how to
    ## handle undefined elements in any of the previous syntaxes.
    ## @var{missingflag} must be a character vector or a string scalar with one
    ## of the following values:
    ##
    ## @itemize
    ## @item @qcode{'omitundefined'}, which is the default, ignores all
    ## undefined elements and returns the minimum of the remaining elements.
    ## If all elements along the operating dimension are undefined, then it
    ## returns an undefined element.  @qcode{'omitnan'} can also be used as
    ## equivalent to @qcode{'omitundefined'}.
    ## @item @qcode{'includeundefined'} returns an undefined element if there
    ## are any undefined elements along the operating dimension.
    ## @qcode{'includenan'} can also be used as equivalent to
    ## @qcode{'includeundefined'}.
    ## @end itemize
    ##
    ## @end deftypefn
    function [C, index] = min (A, B = [], varargin)
      ## Check for ordinal categorical array
      if (! A.isOrdinal)
        error ("categorical.min: categorical array A is not ordinal.");
      endif
      if (numel (varargin) > 3)
        error ("categorical.min: too many input arguments.");
      endif
      ## Handle categorical specific missing flag
      idx = strcmp ('omitundefined', varargin);
      if (any (idx))
        varargin(idx) = 'omitnan';
      endif
      idx = strcmp ('includeundefined', varargin);
      if (any (idx))
        varargin(idx) = 'includenan';
      endif
      ## Create output array
      C = A;
      ## Minimum of one array
      if (isempty (B) && isa (B, 'double'))
        A_d = double (A);
        if (nargin == 1)
          [C_d, index] = min (A_d);
        else
          [C_d, index] = min (A_d, [], varargin{:});
        endif
        ## Fix missing codes
        C.isMissing = isnan (C_d);
        C.code = uint16 (C_d);
      ## Minimum of two arrays
      else
        ## No second output allowed
        if (nargout > 1)
          error (strcat ("categorical.min: two output arguments are", ...
                         " not supported for two input arrays."));
        endif
        ## Check for ordinal categorical array
        if (! isa (B, 'categorical'))
          error ("categorical.min: input array B must be categorical.");
        endif
        if (! B.isOrdinal)
          error ("categorical.min: categorical array B must be ordinal.");
        endif
        ## Check for same categories (including their order)
        Acats = categories (A);
        Bcats = categories (B);
        if (! isequal (Acats, Bcats))
          error (strcat ("categorical.min: categorical arrays must have", ...
                         " the same set of categories, including their", ...
                         " order."));
        endif
        ## Process codes and missing values
        A_d = double (A);
        B_d = double (B);
        C_d = min (A_d, B_d, varargin{:});
        ## Fix missing codes
        C.isMissing = isnan (C_d);
        C.code = uint16 (C_d);
      endif
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn  {categorical} {@var{C} =} max (@var{A})
    ## @deftypefnx {categorical} {[@var{C}, @var{index}] =} max (@var{A})
    ## @deftypefnx {categorical} {@var{C} =} max (@var{A}, @qcode{[]}, @var{dim})
    ## @deftypefnx {categorical} {@var{C} =} max (@var{A}, @qcode{[]}, @var{vecdim})
    ## @deftypefnx {categorical} {@var{C} =} max (@var{A}, @qcode{[]}, @qcode{'all'})
    ## @deftypefnx {categorical} {[@var{C}, @var{index}] =} max (@var{A}, @qcode{[]}, @qcode{'linear'})
    ## @deftypefnx {categorical} {[@var{C}, @var{index}] =} max (@var{A}, @qcode{[]}, @dots{}, @qcode{'linear'})
    ## @deftypefnx {categorical} {@var{C} =} max (@var{A}, @var{B})
    ## @deftypefnx {categorical} {[@dots{}] =} max (@dots{}, @var{missingflag})
    ##
    ## Largest elements in ordinal categorical arrays.
    ##
    ## @code{@var{C} = max (@var{A})} returns the largest element in ordinal
    ## categorical vector @var{A}.  If @var{A} is a matrix, @code{max (@var{A})}
    ## returns a row vector with the largest element from each column.  For
    ## multidimensional arrays, @code{max (@var{A})} operates along the first
    ## non-singleton dimension.
    ##
    ## @code{[@var{C}, @var{index}] = max (@var{A})} also returns the indices of
    ## the maximum values in @var{index}, which has the same size as @var{C}.
    ## When the operating dimension contains more than one maximal elements, the
    ## index of the first one is returned.
    ##
    ## @code{@var{C} = max (@var{A}, @qcode{[]}, @var{dim})} operates along the
    ## dimension specified by @var{dim}.
    ##
    ## @code{@var{C} = max (@var{A}, @qcode{[]}, @var{vecdim})} operates on all
    ## the elements contained in the dimensions specified by @var{vecdim}, which
    ## must be a numeric vector of non-repeating positive integers.  Any values
    ## in @var{vecdim} indexing dimensions larger that the actual array @var{A}
    ## are ignored.
    ##
    ## @code{@var{C} = max (@var{A}, @qcode{[]}, @qcode{'all'})} operates on all
    ## dimensions and returns the largest element in @var{A}.
    ##
    ## @code{[@var{C}, @var{index}] = max (@var{A}, @qcode{[]}, @dots{})} also
    ## returns the first index of the maximum values in @var{index}.  The second
    ## output is only valid when @code{max} operates on a single input array.
    ## Setting the @qcode{'linear'} flag returns the linear index to the
    ## corresponding maximum values in @var{A}.
    ##
    ## @code{@var{C} = max (@var{A}, @var{B})} returns an ordinal categorical
    ## array @var{C} with the largest elements from @var{A} and @var{B}, which
    ## both must be ordinal categorical arrays of compatible sizes with the same
    ## set and ordering of categories.  Compatible size means that @var{A} and
    ## @var{B} can be the same size, one can be scalar, or for every dimension,
    ## their dimension sizes must be equal or one of them must be 1.
    ##
    ## @code{[@dots{}] = max (@dots{}, @var{missingflag})} specifies how to
    ## handle undefined elements in any of the previous syntaxes.
    ## @var{missingflag} must be a character vector or a string scalar with one
    ## of the following values:
    ##
    ## @itemize
    ## @item @qcode{'omitundefined'}, which is the default, ignores all
    ## undefined elements and returns the maximum of the remaining elements.  If
    ## all elements along the operating dimension are undefined, then it returns
    ## an undefined element.  @qcode{'omitnan'} may also be used as equivalent
    ## to @qcode{'omitundefined'}.
    ## @item @qcode{'includeundefined'} returns an undefined element if there
    ## any undefined elements along the operating dimension.
    ## @qcode{'includenan'} may also be used as equivalent to
    ## @qcode{'includeundefined'}.
    ## @end itemize
    ##
    ## @end deftypefn
    function [C, index] = max (A, B = [], varargin)
      ## Check for ordinal categorical array
      if (! A.isOrdinal)
        error ("categorical.max: categorical array A is not ordinal.");
      endif
      if (numel (varargin) > 3)
        error ("categorical.max: too many input arguments.");
      endif
      ## Handle categorical specific missing flag
      idx = strcmp ('omitundefined', varargin);
      if (any (idx))
        varargin(idx) = 'omitnan';
      endif
      idx = strcmp ('includeundefined', varargin);
      if (any (idx))
        varargin(idx) = 'includenan';
      endif
      ## Create output array
      C = A;
      ## Minimum of one array
      if (isempty (B) && isa (B, 'double'))
        A_d = double (A);
        if (nargin == 1)
          [C_d, index] = max (A_d);
        else
          [C_d, index] = max (A_d, [], varargin{:});
        endif
        ## Fix missing codes
        C.isMissing = isnan (C_d);
        C.code = uint16 (C_d);
      ## Minimum of two arrays
      else
        ## No second output allowed
        if (nargout > 1)
          error (strcat ("categorical.max: two output arguments are", ...
                         " not supported for two input arrays."));
        endif
        ## Check for ordinal categorical array
        if (! isa (B, 'categorical'))
          error ("categorical.max: input array B must be categorical.");
        endif
        if (! B.isOrdinal)
          error ("categorical.max: categorical array B must be ordinal.");
        endif
        ## Check for same categories (including their order)
        Acats = categories (A);
        Bcats = categories (B);
        if (! isequal (Acats, Bcats))
          error (strcat ("categorical.max: categorical arrays must have", ...
                         " the same set of categories, including their", ...
                         " order."));
        endif
        ## Process codes and missing values
        A_d = double (A);
        B_d = double (B);
        C_d = max (A_d, B_d, varargin{:});
        ## Fix missing codes
        C.isMissing = isnan (C_d);
        C.code = uint16 (C_d);
      endif
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn  {categorical} {@var{B} =} median (@var{A})
    ## @deftypefnx {categorical} {@var{B} =} median (@var{A}, @var{dim})
    ## @deftypefnx {categorical} {@var{B} =} median (@var{A}, @var{vecdim})
    ## @deftypefnx {categorical} {@var{B} =} median (@var{A}, @qcode{'all'})
    ## @deftypefnx {categorical} {@var{B} =} median (@dots{}, @var{missingflag})
    ##
    ## Median value of an ordinal categorical array.
    ##
    ## @code{@var{B} = median (@var{A})} returns the median of the elements in
    ## ordinal categorical vector @var{A}.  If @var{A} is a matrix,
    ## @code{median (@var{A})} returns a row vector with the median element
    ## from each column.  For multidimensional arrays, @code{median (@var{A})}
    ## operates along the first non-singleton dimension.  @var{B} is also
    ## ordinal with the same ordered categories as @var{A}.  For even number of
    ## elements along the operating dimension, the returned median value is
    ## either the midway category between the two middle elements or the larger
    ## of the two categories midway between the two middle elements.
    ##
    ## @code{@var{B} = median (@var{A}, @var{dim})} operates along the dimension
    ## specified by @var{dim}.
    ##
    ## @code{@var{B} = median (@var{A}, @var{vecdim})} operates on all the
    ## elements contained in the dimensions specified by @var{vecdim}, which
    ## must be a numeric vector of non-repeating positive integers.  Any values
    ## in @var{vecdim} indexing dimensions larger that the actual array @var{A}
    ## are ignored.
    ##
    ## @code{@var{C} = median (@var{A}, @qcode{[]}, @qcode{'all'})} operates on
    ## all dimensions and returns the median element in @var{A}.
    ##
    ## @code{@var{C} = median (@dots{}, @var{missingflag})} specifies how to
    ## handle undefined elements in any of the previous syntaxes.
    ## @var{missingflag} must be a character vector or a string scalar with one
    ## of the following values:
    ##
    ## @itemize
    ## @item @qcode{'omitundefined'} ignores all undefined elements and returns
    ## the median of the remaining elements.  If all elements along the
    ## operating dimension are undefined, then it returns an undefined element.
    ## @qcode{'omitnan'} may also be used as equivalent to
    ## @qcode{'omitundefined'}.
    ## @item @qcode{'includeundefined'}, which is the default, returns an
    ## undefined element if there any undefined elements along the operating
    ## dimension. @qcode{'includenan'} may also be used as equivalent to
    ## @qcode{'includeundefined'}.
    ## @end itemize
    ##
    ## @end deftypefn
    function B = median (A, varargin)
      ## Check for ordinal categorical array
      if (! A.isOrdinal)
        error ("categorical.median: categorical array A is not ordinal.");
      endif
      if (numel (varargin) > 2)
        error ("categorical.median: too many input arguments.");
      endif
      ## Handle categorical specific missing flag
      idx = strcmp ('omitundefined', varargin);
      if (any (idx))
        varargin(idx) = 'omitnan';
      endif
      idx = strcmp ('includeundefined', varargin);
      if (any (idx))
        varargin(idx) = 'includenan';
      endif
      ## Compute median
      B_d = median (double (A), varargin{:});
      ## Save median to output array
      B = A;
      B.isMissing = isnan (B_d);
      B.code = uint16 (round (B_d));
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn  {categorical} {@var{M} =} mode (@var{A})
    ## @deftypefnx {categorical} {[@var{M}, @var{F}] =} mode (@var{A})
    ## @deftypefnx {categorical} {[@var{M}, @var{F}, @var{C}] =} mode (@var{A})
    ## @deftypefnx {categorical} {[@dots{}] =} mode (@var{A}, @var{dim})
    ## @deftypefnx {categorical} {[@dots{}] =} mode (@var{A}, @var{vecdim})
    ## @deftypefnx {categorical} {[@dots{}] =} mode (@var{A}, @qcode{'all'})
    ##
    ## Most frequent element in a categorical array.
    ##
    ## @code{@var{M} = mode (@var{A})} returns the most frequent element in the
    ## categorical vector @var{A}.  If @var{A} is a matrix,
    ## @code{mode (@var{A})} returns a row vector with the most frequent element
    ## from each column.  For multidimensional arrays, @code{mode (@var{A})}
    ## operates along the first non-singleton dimension.  @var{B} is also a
    ## categorical array with the same categories as @var{A}.  For multiple
    ## elements with the same maximum frequency along the operating dimension,
    ## the element from the category that occurs first in @var{A} is returned.
    ##
    ## @code{[@var{M}, @var{F}] = mode (@var{A})} also returns a numeric array
    ## @var{F}, which has the same size as @var{M} and it contains the number of
    ## occurrences of each corresponding element of @var{M}.
    ##
    ## @code{[@var{M}, @var{F}, @var{C}] = mode (@var{A})} also returns a cell
    ## array @var{C}, which has the same size as @var{M} and each element is a
    ## sorted categorical vector of all the values with the same maximum
    ## frequency of the corresponding element of @var{M}.
    ##
    ## @code{@var{B} = mode (@var{A}, @var{dim})} operates along the dimension
    ## specified by @var{dim}.
    ##
    ## @code{@var{B} = mode (@var{A}, @var{vecdim})} operates on all the
    ## elements contained in the dimensions specified by @var{vecdim}, which
    ## must be a numeric vector of non-repeating positive integers.  Any values
    ## in @var{vecdim} indexing dimensions larger that the actual array @var{A}
    ## are ignored.
    ##
    ## @code{@var{C} = mode (@var{A}, @qcode{[]}, @qcode{'all'})} operates on
    ## all dimensions and returns the most frequent element in @var{A}.
    ##
    ## @end deftypefn
    function [M, F, C] = mode (A, dim = [])
      ## Handle 'all' option first
      if (strcmpi (dim, 'all'))
        A = subset (A, ':');
        dim = [];
      endif
      ## Simple case, only one input argument (or 'all' flag used)
      if (isempty (dim))
        [m, F, c] = mode (double (A));
        M = A;
        M.code = uint16 (m);
        M.isMissing = isnan (m);
        C = cell (size (c));
        for i = 1:numel (c)
          tmp = A;
          tmp.code = uint16 (c{i});
          tmp.isMissing = isnan (c{i});
          C(i) = tmp;
        endfor
      ## Second input argument is either DIM or VECDIM
      else
        [m, F, c] = mode (double (A), dim);
        M = A;
        M.code = uint16 (m);
        M.isMissing = isnan (m);
        C = cell (size (c));
        for i = 1:numel (c)
          tmp = A;
          tmp.code = uint16 (c{i});
          tmp.isMissing = isnan (c{i});
          C(i) = tmp;
        endfor
      endif
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn  {categorical} {@var{N} =} histcounts (@var{A})
    ## @deftypefnx {categorical} {@var{N} =} histcounts (@var{A}, @var{cats})
    ## @deftypefnx {categorical} {@var{N} =} histcounts (@dots{}, @qcode{'Normalization'}, @var{normtype})
    ## @deftypefnx {categorical} {[@var{N}, @var{cats}] =} histcounts (@dots{})
    ##
    ## Histogram bin counts of a categorical array.
    ##
    ## @code{@var{N} = histcounts (@var{A})} returns a numeric vector @var{N}
    ## with the number of elements of each category in @var{A}.  @var{A} can be
    ## a categorical array of any dimensions, but it is converted internally to
    ## a single column vector.
    ##
    ## @code{@var{N} = histcounts (@var{A}, @var{cats})} returns the number of
    ## elements only for the categories named in @var{cats}, in the order they
    ## are named there.  @var{cats} may be a categorical array, a string array,
    ## or a cell array of character vectors, and the names must be unique.  A
    ## name that is not a category of @var{A} is not an error: it counts zero.
    ## Note that @qcode{<undefined>} elements are counted under no name at all,
    ## so they never appear in @var{N}.
    ##
    ## @code{@var{N} = histcounts (@dots{}, @qcode{'Normalization'},
    ## @var{normtype})} specifies how to normalize the histogram values returned
    ## in @var{N} with any of the following options specified in @var{normtype}:
    ##
    ## @itemize
    ## @item @qcode{'count'}, which is the default, returns the number of
    ## elements in each category.
    ## @item @qcode{'countdensity'} is the same as @qcode{'count'}, since the
    ## bin width in categorical arrays is always equal to 1.
    ## @item @qcode{'probability'} returns the number of elements in each
    ## category relative to the total number of elements in @var{A}.
    ## @item @qcode{'percentage'} returns the percentage of the elements of
    ## @var{A} that fall in each category.
    ## @item @qcode{'pdf'} is the same as @qcode{'probability'}, since the bin
    ## width in categorical arrays is always equal to 1.
    ## @item @qcode{'cumcount'} returns the cumulative number of elements in
    ## each category and all previous categories.
    ## @item @qcode{'cdf'} returns the cumulative number of elements in
    ## each category and all previous categories relative to the total number of
    ## elements in @var{A}.
    ## @end itemize
    ##
    ## @code{[@var{N}, @var{cats}] = histcounts (@dots{})} also returns the
    ## corresponding categories of @var{A} for each count in @var{N}.
    ## @var{cats} is a cell array of character vectors with the same size as
    ## @var{N}.
    ##
    ## @end deftypefn
    function [N, cats] = histcounts (A, varargin)
      ## Parse and validate optional Name-Value paired argument
      optNames = {'Normalization'};
      dfValues = {'count'};
      [normtype, args] = parsePairedArguments (optNames, dfValues, varargin(:));
      vnt = {'count', 'countdensity', 'probability', 'percentage', 'pdf', ...
             'cumcount', 'cdf'};
      if (isa (normtype, 'string'))
        normtype = char (normtype);
      endif
      ## The type is matched without regard to case, as MATLAB does
      if (! any (strcmpi (normtype, vnt)))
        error ("categorical.histcounts: invalid 'Normalization' type.");
      endif
      normtype = lower (normtype);

      ## Check for selected categories
      if (! isempty (args))
        cats = args{1};
        if (isa (cats, 'categorical'))
          ## The elements select the categories, in the order they appear;
          ## `categories` would sort them and silently reorder the counts.
          cats = cellstr (cats);
        elseif (isa (cats, 'string'))
          cats = cellstr (cats);
        elseif (! iscellstr (cats))
          error (strcat ("categorical.histcounts: invalid", ...
                         " type for CATS input argument."));
        endif
        ## A name that is not a category of A is not an error; it simply counts
        ## zero, as MATLAB has it.  Repeated names are refused, also as MATLAB
        ## has it -- they would silently return the same count twice.
        if (numel (unique (cats(:))) != numel (cats))
          error (strcat ("categorical.histcounts: input argument CATS must", ...
                         " not contain repeated category names."));
        endif
      else
        cats = A.cats;
      endif
      ## Force cats to row vector
      cats = cats(:)';

      ## Count elements in selected categories.  The counts follow the order the
      ## categories were requested in, which is the order CATS is returned in,
      ## and not the order they happen to have in A.
      codes = A.code(:);
      [known, ccats] = ismember (cats, A.cats);
      ncats = numel (ccats);
      N = zeros (1, ncats);
      for i = 1:ncats
        ## 'ismember' answers 0 for a name A does not have, and 0 is also the
        ## code of an <undefined> element, so a name that is not a category
        ## must be skipped rather than compared -- otherwise it would be
        ## credited with every undefined element in the array.
        if (known(i))
          N(i) = sum (codes == ccats(i));
        endif
      endfor

      ## Apply normalization scheme
      if (any (strcmp (normtype, {'probability', 'pdf'})))
        N = N ./ numel (codes);
      elseif (strcmp (normtype, 'percentage'))
        N = 100 * N ./ numel (codes);
      elseif (strcmp (normtype, 'cumcount'))
        N = cumsum (N);
      elseif (strcmp (normtype, 'cdf'))
        N = cumsum (N ./ numel (codes));
      endif
    endfunction

  endmethods

################################################################################
##                   ** Sort, Filter, and Set Operations **                   ##
################################################################################
##                             Available Methods                              ##
##                                                                            ##
## 'maxk'             'mink'             'sort'             'sortrows'        ##
## 'topkrows'         'unique'           'intersect'        'setdiff'         ##
## 'setxor'           'union'                                                 ##
##                                                                            ##
################################################################################

  methods (Access = public)

    ## -*- texinfo -*-
    ## @deftypefn  {categorical} {@var{B} =} maxk (@var{A}, @var{K})
    ## @deftypefnx {categorical} {@var{B} =} maxk (@var{A}, @var{K}, @var{dim})
    ## @deftypefnx {categorical} {[@var{B}, @var{index}] =} maxk (@var{A}, @dots{})
    ##
    ## Find the largest elements in an ordinal categorical array.
    ##
    ## @code{@var{B} = maxk (@var{A}, @var{K})} returns the @var{K} largest
    ## elements of the categorical array @var{A} in descending order.  @var{A}
    ## must be ordinal, since the elements are ranked by the order of their
    ## categories.  If @var{A} is a vector, then @var{B} is a vector with
    ## @var{K} elements.  If @var{A} is a matrix, then @code{maxk} operates
    ## along each column of @var{A} and @var{B} has @var{K} rows.  For
    ## multidimensional arrays, @code{maxk} operates along the first
    ## non-singleton dimension.  @var{B} keeps the categories of @var{A},
    ## including their order.
    ##
    ## @code{@var{B} = maxk (@var{A}, @var{K}, @var{dim})} operates along the
    ## dimension specified by @var{dim}.
    ##
    ## @var{K} must be a nonnegative integer scalar.  If @var{K} is larger than
    ## the number of elements along the operating dimension, then all of them
    ## are returned.
    ##
    ## Missing elements (@qcode{<undefined>}) are not ranked.  They are appended
    ## after the ranked elements in their original order, and hence they only
    ## appear in @var{B} when @var{K} exceeds the number of defined elements
    ## along the operating dimension.  Unlike @code{sort}, @code{maxk} has no
    ## @qcode{'MissingPlacement'} option, since undefined elements are always
    ## placed last.  Elements comparing as equal keep their original order.
    ##
    ## @code{[@var{B}, @var{index}] = maxk (@var{A}, @dots{})} also returns an
    ## index array containing the indices of the returned elements of @var{A}
    ## along the operating dimension.
    ##
    ## @end deftypefn
    function [B, index] = maxk (A, K, varargin)
      if (nargin < 2)
        error ("categorical.maxk: too few input arguments.");
      endif
      ## Check for ordinal categorical array
      if (! A.isOrdinal)
        error ("categorical.maxk: categorical array A is not ordinal.");
      endif
      [index, lidx, errmsg] = __minmaxk__ (double (A), K, true, varargin);
      if (! isempty (errmsg))
        error ("categorical.maxk: %s", errmsg);
      endif
      B = subset (A, lidx);
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn  {categorical} {@var{B} =} mink (@var{A}, @var{K})
    ## @deftypefnx {categorical} {@var{B} =} mink (@var{A}, @var{K}, @var{dim})
    ## @deftypefnx {categorical} {[@var{B}, @var{index}] =} mink (@var{A}, @dots{})
    ##
    ## Find the smallest elements in an ordinal categorical array.
    ##
    ## @code{@var{B} = mink (@var{A}, @var{K})} returns the @var{K} smallest
    ## elements of the categorical array @var{A} in ascending order.  @var{A}
    ## must be ordinal, since the elements are ranked by the order of their
    ## categories.  If @var{A} is a vector, then @var{B} is a vector with
    ## @var{K} elements.  If @var{A} is a matrix, then @code{mink} operates
    ## along each column of @var{A} and @var{B} has @var{K} rows.  For
    ## multidimensional arrays, @code{mink} operates along the first
    ## non-singleton dimension.  @var{B} keeps the categories of @var{A},
    ## including their order.
    ##
    ## @code{@var{B} = mink (@var{A}, @var{K}, @var{dim})} operates along the
    ## dimension specified by @var{dim}.
    ##
    ## @var{K} must be a nonnegative integer scalar.  If @var{K} is larger than
    ## the number of elements along the operating dimension, then all of them
    ## are returned.
    ##
    ## Missing elements (@qcode{<undefined>}) are not ranked.  They are appended
    ## after the ranked elements in their original order, and hence they only
    ## appear in @var{B} when @var{K} exceeds the number of defined elements
    ## along the operating dimension.  Unlike @code{sort}, @code{mink} has no
    ## @qcode{'MissingPlacement'} option, since undefined elements are always
    ## placed last.  Elements comparing as equal keep their original order.
    ##
    ## @code{[@var{B}, @var{index}] = mink (@var{A}, @dots{})} also returns an
    ## index array containing the indices of the returned elements of @var{A}
    ## along the operating dimension.
    ##
    ## @end deftypefn
    function [B, index] = mink (A, K, varargin)
      if (nargin < 2)
        error ("categorical.mink: too few input arguments.");
      endif
      ## Check for ordinal categorical array
      if (! A.isOrdinal)
        error ("categorical.mink: categorical array A is not ordinal.");
      endif
      [index, lidx, errmsg] = __minmaxk__ (double (A), K, false, varargin);
      if (! isempty (errmsg))
        error ("categorical.mink: %s", errmsg);
      endif
      B = subset (A, lidx);
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn  {categorical} {@var{B} =} sort (@var{A})
    ## @deftypefnx {categorical} {@var{B} =} sort (@var{A}, @var{dim})
    ## @deftypefnx {categorical} {@var{B} =} sort (@var{A}, @var{direction})
    ## @deftypefnx {categorical} {@var{B} =} sort (@var{A}, @var{dim}, @var{direction})
    ## @deftypefnx {categorical} {@var{B} =} sort (@dots{}, @qcode{'MissingPlacement'}, @var{MP})
    ## @deftypefnx {categorical} {[@var{B}, @var{index}] =} sort (@var{A}, @dots{})
    ##
    ## Sort elements in a categorical array.
    ##
    ## @code{@var{B} = sort (@var{A})} sorts the categorical array @var{A} in
    ## ascending order.  The sorted array @var{B} has the same categories as
    ## @var{A}.  If @var{A} is a matrix, @code{sort (@var{A})} sorts each column
    ## of @var{A} in ascending order.  For multidimensional arrays,
    ## @code{mode (@var{A})} sorts along the first non-singleton dimension.
    ##
    ## @code{@var{B} = sort (@var{A}, @var{dim})} sorts along the dimension
    ## specified by @var{dim}.
    ##
    ## @code{@var{B} = sort (@var{A}, @var{direction})} also specifies the
    ## sorting direction, which can be either @qcode{'ascend'} (default) or
    ## @qcode{'descend'}.
    ##
    ## @code{@var{B} = sort (@dots{}, @qcode{'MissingPlacement'}, @var{MP})}
    ## specifies where to place the missing elements (@qcode{<undefined>})
    ## returned in @var{B} with any of the following options specified in
    ## @var{MP}:
    ##
    ## @itemize
    ## @item @qcode{'auto'}, which is the default, places missing elements last
    ## for ascending sort and first for descending sort.
    ## @item @qcode{'first'} places missing elements first.
    ## @item @qcode{'last'} places missing elements last.
    ## @end itemize
    ##
    ## @code{[@var{B}, @var{index}] = sort (@var{A}, @dots{})} also returns a
    ## sorting index containing the original indices of the elements in the
    ## sorted array.
    ##
    ## @itemize
    ## @item If @var{A} is a vector, then @var{index} contains the original
    ## linear indices of the elements in the sorted vector @var{B} such that
    ## @code{@var{B} = @var{A}(@var{index})}.
    ## @item If @var{A} is an @math{M*N} matrix and @qcode{@var{dim} = 1}, then
    ## @var{index} contains the original row indices of the elements in the
    ## sorted vector @var{B} such that for @qcode{j = 1:N},
    ## @code{@var{B}(:,j) = @var{A}(@var{index}(:,j),j)}.
    ## @end itemize
    ##
    ## @end deftypefn
    function [B, index] = sort (A, varargin)
      ## Parse and validate optional 'MissingPlacement' paired argument
      optNames = {'MissingPlacement'};
      dfValues = {'auto'};
      [MP, args] = parsePairedArguments (optNames, dfValues, varargin(:));
## The values are matched without regard to case, as MATLAB does.
MP = lower (MP);
      if (! ismember (MP, {'auto', 'first', 'last'}))
        error ("categorical.sort: invalid value for 'MissingPlacement'.");
      endif

      ## Force strings to character vectors
      [args{:}] = convertStringsToChars (args{:});

      ## Get direction
      cid = cellfun (@ischar, args);
      if (any (cid))
        dir = lower (args{cid});
        ## Put it back, since args is forwarded to the core sort further down.
        args(cid) = {dir};
      else
        dir = 'ascend';
      endif

      ## Since codes are positive integers and 0 is used for undefined elements,
      ## we assume A.code == 0 for NaN.  However, in ascending order NaNs go to
      ## the end, while 0s at the very top and vice versa.  So we have to
      ## mitigate this in tandem with the 'MissingPlacement' option, by setting
      ## 0s to max(code) + 1 for certain combinations of options.
      code = A.code;
      is_nan = code == 0;
      if (any (is_nan(:)))
        if ((strcmp (dir, 'ascend') && any (strcmp (MP, {'auto', 'last'}))) ||
            (strcmp (dir, 'descend') && any (strcmp (MP, {'auto', 'first'}))))
          ## Get new value for missing elements code
          nan_code = max (code(:)) + 1;
          code(is_nan) = nan_code;
          ## Sort values
          [code, index] = sort (code, args{:});
          ## Get indices of missing values and change them back to 0
          is_nan = code == nan_code;
          code(is_nan) = 0;
        else
          ## ## Sort values without swaping code for undefined elements
          [code, index] = sort (code, args{:});
          is_nan = code == 0;
        endif
      else
        ## Sort values with no undefined elements
        [code, index] = sort (code, args{:});
      endif

      ## Populate output categorical array
      B = A;
      B.code = uint16 (code);
      B.isMissing = is_nan;
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn  {categorical} {@var{B} =} sortrows (@var{A})
    ## @deftypefnx {categorical} {@var{B} =} sortrows (@var{A}, @var{col})
    ## @deftypefnx {categorical} {@var{B} =} sortrows (@var{A}, @var{direction})
    ## @deftypefnx {categorical} {@var{B} =} sortrows (@var{A}, @var{col}, @var{direction})
    ## @deftypefnx {categorical} {@var{B} =} sortrows (@dots{}, @qcode{'MissingPlacement'}, @var{MP})
    ## @deftypefnx {categorical} {[@var{B}, @var{index}] =} sortrows (@var{A}, @dots{})
    ##
    ## Sort rows in a categorical array.
    ##
    ## @code{@var{B} = sortrows (@var{A})} sorts the rows of the 2-D categorical
    ## array @var{A} in ascending order.  The sorted array @var{B} has the same
    ## categories as @var{A}.
    ##
    ## @code{@var{B} = sortrows (@var{A}, @var{col})} sorts @var{A} according to
    ## to the columns specified by the numeric vector @var{col}, which must
    ## explicitly contain non-zero integers whose absolute values index existing
    ## columns in @var{A}.  Positive elements sort the corresponding columns in
    ## ascending order, while negative elements sort the corresponding columns
    ## in descending order.
    ##
    ## @code{@var{B} = sortrows (@var{A}, @var{direction})} also specifies the
    ## sorting direction, which can be either @qcode{'ascend'} (default) or
    ## @qcode{'descend'} applying to all columns in @var{A}.  Alternatively,
    ## @var{direction} can be a cell array of character vectors specifying
    ## the sorting direction for each individual column of @var{A}, in which
    ## case the number of elements in @var{direction} must equal the number of
    ## columns in @var{A}.
    ##
    ## @code{@var{B} = sortrows (@var{A}, @var{col}, @var{direction})} sorts the
    ## categorical array @var{A} according to the columns specified in @var{col}
    ## using the corresponding sorting direction specified in @var{direction}.
    ## In this case, the sign of the values in @var{col} is ignored.  @var{col}
    ## and @var{direction} must have the same length, but not necessarily the
    ## same number of elements as the columns in @var{A}.
    ##
    ## @code{@var{B} = sortrows (@dots{}, @qcode{'MissingPlacement'}, @var{MP})}
    ## specifies where to place the missing elements (@qcode{<undefined>})
    ## returned in @var{B} with any of the following options specified in
    ## @var{MP}:
    ##
    ## @itemize
    ## @item @qcode{'auto'}, which is the default, places missing elements last
    ## for ascending sort and first for descending sort.
    ## @item @qcode{'first'} places missing elements first.
    ## @item @qcode{'last'} places missing elements last.
    ## @end itemize
    ##
    ## @code{[@var{B}, @var{index}] = sortrows (@var{A}, @dots{})} also returns
    ## an index vector containing the original row indices of @var{A} in the
    ## sorted matrix @var{B} such that @code{@var{B} = @var{A}(@var{index},:)}.
    ##
    ## @end deftypefn
    function [B, index] = sortrows (A, varargin)
      if (ndims (A) != 2)
        error ("categorical.sortrows: A must be a 2-D matrix.");
      endif

      ## Parse and validate optional 'MissingPlacement' paired argument
      optNames = {'MissingPlacement'};
      dfValues = {'auto'};
      [MP, args] = parsePairedArguments (optNames, dfValues, varargin(:));
## The values are matched without regard to case, as MATLAB does.
MP = lower (MP);
      if (! any (strcmp (MP, {'auto', 'first', 'last'})))
        error ("categorical.sort: invalid value for 'MissingPlacement'.");
      endif

      ## Parse COL / DIRECTION input
      nc = size (A, 2);
      col = [1:nc];  # default ascending direction
      dir_flag = false;
      if (numel (args) > 2)
        error ("categorical.sortrows: too many input arguments.");
      endif
      if (numel (args) > 0)
        col = args{1};
        if (isnumeric (col))
          if (! isvector (col) || fix (col) != col)
            error (strcat ("categorical.sortrows: COL must be a vector", ...
                           " of nonzero integers indexing columns in A."));
          endif
          if (max (abs (col)) > nc)
            error ("categorical.sortrows: COL indexes non-existing column.");
          endif
        elseif (isvector (col) && (ischar (col) || iscellstr (col) ||
                                   isa (col, 'string')))
          direction = lower (cellstr (col));
          if (! all (ismember (direction, {'ascend', 'descend'})))
            error (strcat ("categorical.sortrows: DIRECTION input must", ...
                           " contain either 'ascend' or 'descend' values."));
          endif
          ## Apply scalar expansion
          if (isscalar (direction))
            direction = repmat (direction, 1, nc);
          endif
          if (numel (direction) != nc)
            error (strcat ("categorical.sortrows: DIRECTION", ...
                           " does not match the columns in A."));
          endif
          ## Assign DIRECTION to COL
          col = [1:nc];
          idx = strcmp (direction, 'descend');
          col(idx) = - col(idx);
          dir_flag = true;
        else
          error ("categorical.sortrows: invalid type for COL argument.");
        endif
      endif
      if (numel (args) > 1)
        if (dir_flag)
          error ("categorical.sortrows: invalid third input argument.");
        endif
        if ((isvector (args{2}) && ischar (args{2})) || isa (args{2}, 'string'))
          direction = lower (cellstr (args{2}));
        elseif (isvector (args{2}) && iscellstr (args{2}))
          direction = args{2};
        else
          error ("categorical.sortrows: invalid type for DIRECTION argument.");
        endif
        if (! all (ismember (direction, {'ascend', 'descend'})))
          error (strcat ("categorical.sortrows: DIRECTION input must", ...
                         " contain either 'ascend' or 'descend' values."));
        endif
        ## Assign DIRECTION to COL
        if (isscalar (direction) && strcmp (direction, 'ascend'))
          col = abs (col);
        elseif (isscalar (direction) && strcmp (direction, 'descend'))
          col = - abs (col);
        else
          if (numel (direction) != numel (col))
            error (strcat ("categorical.sortrows: DIRECTION", ...
                           " does not match the elements in COL."));
          endif
          col = abs (col);
          idx = strcmp (direction, 'descend');
          col(idx) = - col(idx);
        endif
      endif

      ## Since codes are positive integers and 0 is used for undefined elements,
      ## we assume A.code == 0 for NaN.  However, in ascending order NaNs go to
      ## the end, while 0s at the very top and vice versa.  So we have to
      ## mitigate this in tandem with the 'MissingPlacement' option, by setting
      ## 0s to max(code) + 1 for certain combinations of options.
      code = A.code;
      is_nan = code == 0;
      if (any (is_nan(:)))
        ## Get new value for missing elements code
        nan_code = max (code(:)) + 1;
        asc_cols = [];
        des_cols = [];
        if (any (strcmp (MP, {'auto', 'last'})))
          ## Change codes only in ascending columns
          asc_cols = col(col > 0);
          if (! isempty (asc_cols))
            asc_code = code(:, asc_cols);
            a_is_nan = asc_code == 0;
            asc_code(a_is_nan) = nan_code;
            code(:, asc_cols) = asc_code;
          endif
        endif
        if (any (strcmp (MP, {'auto', 'first'})))
          ## Change codes only in descending columns
          des_cols = abs (col(col < 0));
          if (! isempty (des_cols))
            des_code = code(:, des_cols);
            d_is_nan = des_code == 0;
            des_code(d_is_nan) = nan_code;
            code(:, des_cols) = des_code;
          endif
        endif
        ## Sort values
        [code, index] = sortrows (code, col);
        ## Get indices of missing values and change them back to 0
        adcols = [asc_cols, des_cols];
        if (! isempty (adcols))
          adcode = code(:, [asc_cols, des_cols]);
          is_nan = adcode == nan_code;
          adcode(is_nan) = 0;
          code(:, [asc_cols, des_cols]) = adcode;
        endif
        ## Re-index missing values after sorting
        is_nan = code == 0;
      else
        ## Sort values with no undefined elements
        [code, index] = sortrows (code, col);
      endif

      ## Populate output categorical array
      B = A;
      B.code = uint16 (code);
      B.isMissing = is_nan;
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn  {categorical} {@var{B} =} topkrows (@var{A}, @var{K})
    ## @deftypefnx {categorical} {@var{B} =} topkrows (@var{A}, @var{K}, @var{col})
    ## @deftypefnx {categorical} {@var{B} =} topkrows (@var{A}, @var{K}, @var{direction})
    ## @deftypefnx {categorical} {@var{B} =} topkrows (@var{A}, @var{K}, @var{col}, @var{direction})
    ##
    ## Top K sorted rows of categorical array.
    ##
    ## @code{@var{B} = topkrows (@var{A}, @var{K})} returns the top @var{K} rows
    ## of the 2-D categorical array @var{A} sorted in descending order as a
    ## group.  @var{K} must be a nonnegative integer scalar.  If @var{K} is
    ## larger than the number of rows in @var{A}, then all of them are returned.
    ## @var{B} keeps the categories of @var{A}, including their order.
    ##
    ## Missing elements (@qcode{<undefined>}) are not ranked.  Within each sort
    ## column they are placed after the elements that are defined, whichever
    ## direction is asked for, so a row is demoted only by a missing element in
    ## a column that actually decides its position.  Rows comparing as equal
    ## keep their original order.
    ##
    ## @code{@var{B} = topkrows (@var{A}, @var{K}, @var{col})} returns the top
    ## @var{K} rows of the 2-D categorical array @var{A} sorted according to the
    ## columns specified by the numeric vector @var{col}, which must contain
    ## positive integers indexing existing columns in @var{A}.  Columns are used
    ## as sort keys in the order given, and those not listed are not used at
    ## all.  The direction is descending unless @var{direction} says otherwise.
    ##
    ## @code{@var{B} = topkrows (@var{A}, @var{K}, @var{direction})} returns the
    ## top @var{K} rows of the 2-D categorical array @var{A} sorted according to
    ## @var{direction}, which can be either @qcode{'descend'} (default) or
    ## @qcode{'ascend'} applying to all columns in @var{A}.  Alternatively,
    ## @var{direction} can be a cell array of character vectors specifying
    ## the sorting direction for each individual column of @var{A}, in which
    ## case the number of elements in @var{direction} must equal the number of
    ## columns in @var{A}.
    ##
    ## @code{@var{B} = topkrows (@var{A}, @var{K}, @var{col}, @var{direction})}
    ## returns the top @var{K} rows of the 2-D categorical array @var{A} sorted
    ## according to the columns specified in @var{col} using the corresponding
    ## sorting direction specified in @var{direction}.  @var{col} and
    ## @var{direction} must have the same length, but not necessarily the same
    ## number of elements as the columns in @var{A}.
    ##
    ## @code{@var{B} = topkrows (@dots{}, @qcode{'MissingPlacement'}, @var{MP})}
    ## specifies where the missing elements (@qcode{<undefined>}) are placed
    ## within each sort column, with any of the following options specified in
    ## @var{MP}:
    ##
    ## @itemize
    ## @item @qcode{'last'}, which is the default, places missing elements last
    ## whichever direction is asked for.
    ## @item @qcode{'first'} places missing elements first.
    ## @item @qcode{'auto'} places missing elements last for an ascending sort
    ## and first for a descending one, as @code{sortrows} does.
    ## @end itemize
    ##
    ## This is an Octave extension: MATLAB has no such option here and always
    ## ranks as @qcode{'last'} does.
    ##
    ## @code{[@var{B}, @var{index}] = topkrows (@var{A}, @dots{})} also returns
    ## an index vector containing the original row indices of @var{A} in
    ## @var{B}, such that @code{@var{B} = @var{A}(@var{index},:)}.
    ##
    ## @end deftypefn
    function [B, index] = topkrows (A, K, varargin)
      ## Check input arguments
      if (nargin < 2)
        error ("categorical.topkrows: too few input arguments.");
      endif
      if (ndims (A) != 2)
        error ("categorical.topkrows: A must be a 2-D matrix.");
      endif
      [col, direction, MP, errmsg] = ...
                      __topkrowsargs__ (K, columns (A), varargin);
      if (! isempty (errmsg))
        error ("categorical.topkrows: %s", errmsg);
      endif
      ## Sort rows, keeping undefined elements out of the ranking
      if (isempty (col))
        [B, index] = sortrows (A, direction, 'MissingPlacement', MP);
      else
        [B, index] = sortrows (A, col, direction, 'MissingPlacement', MP);
      endif
      ## Return top K rows
      if (K < numel (index))
        B = subset (B, 1:K, ':');
        index = index(1:K);
      endif
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn  {categorical} {@var{B} =} unique (@var{A})
    ## @deftypefnx {categorical} {@var{B} =} unique (@var{A}, @var{setOrder})
    ## @deftypefnx {categorical} {@var{B} =} unique (@var{A}, @var{occurrence})
    ## @deftypefnx {categorical} {@var{B} =} unique (@var{A}, @var{setOrder}, @var{occurrence})
    ## @deftypefnx {categorical} {@var{B} =} unique (@var{A}, @var{occurrence}, @var{setOrder})
    ## @deftypefnx {categorical} {@var{B} =} unique (@var{A}, @dots{}, @qcode{'rows'})
    ## @deftypefnx {categorical} {[@var{B}, @var{ixA}, @var{ixB}] =} unique (@dots{})
    ##
    ## Unique values in a categorical array.
    ##
    ## @code{@var{B} = unique (@var{A})} returns the unique values of the
    ## categorical array @var{A} in the categorical vector @var{B} sorted
    ## according to the order of categories in @var{A}.  @var{B} retains the
    ## same categories as @var{A}.  If @var{A} is a row vector, then @var{B}
    ## is also a row vector, otherwise @code{unique} returns a column vector.
    ##
    ## @code{@var{B} = unique (@var{A}, @var{setOrder})} returns the unique
    ## values of the categorical array @var{A} in an order as specified by
    ## @var{setOrder}, which can be either of the following values:
    ##
    ## @itemize
    ## @item @qcode{'sorted'} (default) returns the unique values sorted in
    ## ascending order.
    ## @item @qcode{'stable'} returns the unique values according to their order
    ## of occurrence.
    ## @end itemize
    ##
    ## @code{@var{B} = unique (@var{A}, @var{occurrence})} returns the unique
    ## values of the categorical array @var{A} according to their order of
    ## occurrence.  @var{occurrence} can be either of the following values:
    ##
    ## @itemize
    ## @item @qcode{'first'} (default) returns the first occurrence of each
    ## unique value, i.e. the lowest possible indices are returned.
    ## @item @qcode{'last'} returns the last occurrence of each unique value,
    ## i.e. the highest possible indices are returned.
    ## @end itemize
    ##
    ## You can specify @var{setOrder} and @var{occurrence} arguments together.
    ##
    ## @code{@var{B} = unique (@var{A}, @dots{}, @qcode{'rows'})} returns the
    ## unique rows of @var{A} by treating each row as a single entity.  The
    ## @qcode{'rows'} option can be used alone or in any combination with the
    ## @var{setOrder} and @var{occurrence} arguments.  @qcode{'rows'} can be
    ## placed at any position in the function's argument list after the input
    ## array @var{A}.  However, this syntax is only valid for 2-dimensional
    ## categorical arrays.
    ##
    ## @code{[@var{B}, @var{ixA}, @var{ixB}] = unique (@dots{})} also returns
    ## index vectors @var{ixA} and @var{ixB} using any of the previous syntaxes.
    ## @var{ixA} and @var{ixB} map the arrays @var{A} and @var{B} to one another
    ## such that @qcode{@var{B} = @var{A}(@var{ixA})} and
    ## @qcode{@var{A} = @var{B}(@var{ixB})}.  When the @qcode{'rows'} optional
    ## argument is specified, then @qcode{@var{B} = @var{A}(@var{ixA},:)} and
    ## @qcode{@var{A} = @var{B}(@var{ixB},:)}.
    ##
    ## @end deftypefn
    function [B, ixA, ixB] = unique (A, varargin)
      ## 'legacy' option is not supported
      if (any (strcmp ('legacy', varargin)))
        error ("duration.unique: 'legacy' option is not supported.");
      endif
      code = double (A);
      [~, ixA, ixB] = __unique__ (code, varargin{:});
      if (any (strcmp ('rows', varargin)))
        B = subset (A, ixA, ':');
      else
        B = subset (A, ixA);
      endif
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn  {categorical} {@var{C} =} intersect (@var{A}, @var{B})
    ## @deftypefnx {categorical} {@var{C} =} intersect (@var{A}, @var{B}, @qcode{'rows'})
    ## @deftypefnx {categorical} {[@var{C}, @var{ixA}, @var{ixB}] =} intersect (@dots{})
    ## @deftypefnx {categorical} {@dots{} =} intersect (@dots{}, @var{order})
    ##
    ## Set intersection of two categorical arrays.
    ##
    ## @code{@var{C} = intersect (@var{A}, @var{B})} returns the unique common
    ## values of the categorical arrays @var{A} and @var{B}.  Either @var{A} or
    ## @var{B} input arguments may be a character vector, a string array, or a
    ## cell array of character vectors, which is promoted to a categorical array
    ## prior to set intersection.  If both @var{A} and @var{B} are row vectors,
    ## then @var{C} is also a row vector, otherwise @code{intersect} returns a
    ## column vector.
    ##
    ## If categorical arrays @var{A} and @var{B} are ordinal, they must have
    ## the same set and ordering of categories, which is transfered to @var{C}.
    ## If neither are ordinal, the category names of each pair of elements are
    ## compared (they do not need to have the same set of categories) in which
    ## case the categories in @var{C} are the sorted union of the categories in
    ## @var{A} and @var{B}.
    ##
    ## @code{@var{C} = intersect (@var{A}, @var{B}, @qcode{'rows'}} returns the
    ## unique common rows of the categorical matrices @var{A} and @var{B}, which
    ## must have the same number of columns.  By default, the rows in
    ## categorical matrix @var{C} are in sorted order.
    ##
    ## @code{[@var{C}, @var{ixA}, @var{ixB}] = intersect (@dots{})} also returns
    ## index vectors @var{ixA} and @var{ixB} such that
    ## @code{@var{C} = @var{A}(@var{ixA})} and
    ## @code{@var{C} = @var{B}(@var{ixB})}, unless the @qcode{'rows'} optional
    ## argument is given, in which case @code{@var{C} = @var{A}(@var{ixA},:)}
    ## and @code{@var{C} = @var{B}(@var{ixB},:)}.
    ##
    ## @code{@dots{} = intersect (@dots{}, @var{order})} also specifies the
    ## order of the returned unique values.  @var{order} may be either
    ## @qcode{'sorted'}, which is the default behavior, or @qcode{'stable'},
    ## in which case the unique values are returned in order of appearance.
    ##
    ## @end deftypefn
    function [C, ixA, ixB] = intersect (A, B, varargin)
      [C, ixA, ixB] = setop (A, B, 'intersect', varargin{:});
      if (! iscategorical (C))
        error (C);
      endif
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn  {categorical} {@var{C} =} setdiff (@var{A}, @var{B})
    ## @deftypefnx {categorical} {@var{C} =} setdiff (@var{A}, @var{B}, @qcode{'rows'})
    ## @deftypefnx {categorical} {[@var{C}, @var{ixA}] =} setdiff (@dots{})
    ## @deftypefnx {categorical} {@dots{} =} setdiff (@dots{}, @var{order})
    ##
    ## Set difference of two categorical arrays.
    ##
    ## @code{@var{C} = setdiff (@var{A}, @var{B})} returns the unique common
    ## values of the categorical arrays @var{A} and @var{B}.  Either @var{A} or
    ## @var{B} input arguments may be a character vector, a string array, or a
    ## cell array of character vectors, which is promoted to a categorical array
    ## prior to set difference.  If both @var{A} and @var{B} are row vectors,
    ## then @var{C} is also a row vector, otherwise @code{intersect} returns a
    ## column vector.
    ##
    ## If categorical arrays @var{A} and @var{B} are ordinal, they must have
    ## the same set and ordering of categories, which is transfered to @var{C}.
    ## If neither are ordinal, the category names of each pair of elements are
    ## compared (they do not need to have the same set of categories) in which
    ## case the categories in @var{C} are the sorted union of the categories in
    ## @var{A} and @var{B}.
    ##
    ## @code{@var{C} = setdiff (@var{A}, @var{B}, @qcode{'rows'}} returns the
    ## unique common rows of the categorical matrices @var{A} and @var{B}, which
    ## must have the same number of columns.  By default, the rows in
    ## categorical matrix @var{C} are in sorted order.
    ##
    ## @code{[@var{C}, @var{ixA}] = setdiff (@dots{})} also returns the index
    ## vector @var{ixA} such that @code{@var{C} = @var{A}(@var{ixA})}, unless
    ## the @qcode{'rows'} optional argument is given, in which case
    ## @code{@var{C} = @var{A}(@var{ixA},:)}.
    ##
    ## @code{@dots{} = setdiff (@dots{}, @var{order})} also specifies the
    ## order of the returned unique values.  @var{order} may be either
    ## @qcode{'sorted'}, which is the default behavior, or @qcode{'stable'},
    ## in which case the unique values are returned in order of appearance.
    ##
    ## @end deftypefn
    function [C, ixA] = setdiff (A, B, varargin)
      [C, ixA] = setop (A, B, 'setdiff', varargin{:});
      if (! iscategorical (C))
        error (C);
      endif
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn  {categorical} {@var{C} =} setxor (@var{A}, @var{B})
    ## @deftypefnx {categorical} {@var{C} =} setxor (@var{A}, @var{B}, @qcode{'rows'})
    ## @deftypefnx {categorical} {[@var{C}, @var{ixA}, @var{ixB}] =} setxor (@dots{})
    ## @deftypefnx {categorical} {@dots{} =} setxor (@dots{}, @var{order})
    ##
    ## Set exclusive-or of two categorical arrays.
    ##
    ## @code{@var{C} = setxor (@var{A}, @var{B})} returns the unique common
    ## values of the categorical arrays @var{A} and @var{B}.  Either @var{A} or
    ## @var{B} input arguments may be a character vector, a string array, or a
    ## cell array of character vectors, which is promoted to a categorical array
    ## prior to set exclusive-or.  If both @var{A} and @var{B} are row vectors,
    ## then @var{C} is also a row vector, otherwise @code{setxor} returns a
    ## column vector.
    ##
    ## If categorical arrays @var{A} and @var{B} are ordinal, they must have
    ## the same set and ordering of categories, which is transfered to @var{C}.
    ## If neither are ordinal, the category names of each pair of elements are
    ## compared (they do not need to have the same set of categories) in which
    ## case the categories in @var{C} are the sorted union of the categories in
    ## @var{A} and @var{B}.
    ##
    ## @code{@var{C} = setxor (@var{A}, @var{B}, @qcode{'rows'}} returns the
    ## unique common rows of the categorical matrices @var{A} and @var{B}, which
    ## must have the same number of columns.  By default, the rows in
    ## categorical matrix @var{C} are in sorted order.
    ##
    ## @code{[@var{C}, @var{ixA}, @var{ixB}] = setxor (@dots{})} also returns
    ## index vectors @var{ixA} and @var{ixB} such that
    ## @code{@var{C} = @var{A}(@var{ixA})} and
    ## @code{@var{C} = @var{B}(@var{ixB})}, unless the @qcode{'rows'} optional
    ## argument is given, in which case @code{@var{C} = @var{A}(@var{ixA},:)}
    ## and @code{@var{C} = @var{B}(@var{ixB},:)}.
    ##
    ## @code{@dots{} = setxor (@dots{}, @var{order})} also specifies the
    ## order of the returned unique values.  @var{order} may be either
    ## @qcode{'sorted'}, which is the default behavior, or @qcode{'stable'},
    ## in which case the unique values are returned in order of appearance.
    ##
    ## @end deftypefn
    function [C, ixA, ixB] = setxor (A, B, varargin)
      [C, ixA, ixB] = setop (A, B, 'setxor', varargin{:});
      if (! iscategorical (C))
        error (C);
      endif
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn  {categorical} {@var{C} =} union (@var{A}, @var{B})
    ## @deftypefnx {categorical} {@var{C} =} union (@var{A}, @var{B}, @qcode{'rows'})
    ## @deftypefnx {categorical} {[@var{C}, @var{ixA}, @var{ixB}] =} union (@dots{})
    ## @deftypefnx {categorical} {@dots{} =} union (@dots{}, @var{order})
    ##
    ## Set union of two categorical arrays.
    ##
    ## @code{@var{C} = union (@var{A}, @var{B})} returns the unique common
    ## values of the categorical arrays @var{A} and @var{B}.  Either @var{A} or
    ## @var{B} input arguments may be a character vector, a string array, or a
    ## cell array of character vectors, which is promoted to a categorical array
    ## prior to set exclusive-or.  If both @var{A} and @var{B} are row vectors,
    ## then @var{C} is also a row vector, otherwise @code{union} returns a
    ## column vector.
    ##
    ## If categorical arrays @var{A} and @var{B} are ordinal, they must have
    ## the same set and ordering of categories, which is transfered to @var{C}.
    ## If neither are ordinal, the category names of each pair of elements are
    ## compared (they do not need to have the same set of categories) in which
    ## case the categories in @var{C} are the sorted union of the categories in
    ## @var{A} and @var{B}.
    ##
    ## @code{@var{C} = union (@var{A}, @var{B}, @qcode{'rows'}} returns the
    ## unique common rows of the categorical matrices @var{A} and @var{B}, which
    ## must have the same number of columns.  By default, the rows in
    ## categorical matrix @var{C} are in sorted order.
    ##
    ## @code{[@var{C}, @var{ixA}, @var{ixB}] = union (@dots{})} also returns
    ## index vectors @var{ixA} and @var{ixB} such that
    ## @code{@var{C} = @var{A}(@var{ixA})} and
    ## @code{@var{C} = @var{B}(@var{ixB})}, unless the @qcode{'rows'} optional
    ## argument is given, in which case @code{@var{C} = @var{A}(@var{ixA},:)}
    ## and @code{@var{C} = @var{B}(@var{ixB},:)}.
    ##
    ## @code{@dots{} = union (@dots{}, @var{order})} also specifies the
    ## order of the returned unique values.  @var{order} may be either
    ## @qcode{'sorted'}, which is the default behavior, or @qcode{'stable'},
    ## in which case the unique values are returned in order of appearance.
    ##
    ## @end deftypefn
    function [C, ixA, ixB] = union (A, B, varargin)
      [C, ixA, ixB] = setop (A, B, 'union', varargin{:});
      if (! iscategorical (C))
        error (C);
      endif
    endfunction

  endmethods

################################################################################
##                           ** Array Operations **                           ##
################################################################################
##                             Available Methods                              ##
##                                                                            ##
## 'cat'              'horzcat'          'vertcat'          'repmat'          ##
## 'repelem'          'repelems'         'reshape'          'circshift'       ##
## 'permute'          'ipermute'         'transpose'        'ctranspose'      ##
##                                                                            ##
################################################################################

  methods (Access = public)

    ## -*- texinfo -*-
    ## @deftypefn {categorical} {@var{C} =} cat (@var{dim}, @var{A}, @var{B}, @dots{})
    ##
    ## Concatenate categorical arrays.
    ##
    ## @code{@var{C} = cat (@var{dim}, @var{A}, @var{B}, @dots{})} concatenates
    ## categorical arrays @var{A}, @var{B}, @dots{} along dimension @var{dim}.
    ## All input arrays must have the same size except along the operating
    ## dimension @var{dim}.  Any of the input arrays may also be string arrays
    ## or cell arrays of character vectors of compatible size.
    ##
    ## If any input array is an ordinal categorical array, then all inputs must
    ## be ordinal categorical arrays with the same set and ordering of
    ## categories.  In this case, @var{C} is also an ordinal categorical array
    ## with the same set and ordering of categories.  If none of the input
    ## arrays are ordinal, then they do not need to have the same set of
    ## categories.  In this case, categorical array @var{C} contains the union
    ## of the categories from all input arrays.  Protected categorical arrays
    ## can only be concatenated with other arrays that have the same set of
    ## categories but not necessarily in the same order.
    ##
    ## @end deftypefn
    function out = cat (dim, varargin)
      ## Remove empty cell inputs
      varargin(cellfun (@(x) isempty (x) && iscell (x), varargin)) = [];
      if (numel (varargin) == 1)
        out = varargin{1};
        return;
      endif
      ## Identify non-categorical inputs for reordering categories
      firstcatid = find (cellfun (@(x) iscategorical (x), varargin), 1);
      ## Promote everything into categorical arrays
      args = varargin;
      [args{:}] = promote (varargin{:});
      ## Check that all dimensions except DIM are equal
      if (fix (dim) != dim | dim < 1)
        error ("categorical.cat: DIM must be a valid dimension.");
      endif
      sz_dim = cellfun (@(x) size (x), args, "UniformOutput", false);
      n_dims = cellfun (@(x) ndims (x), args, "UniformOutput", false);
      if (! isequal (n_dims{:}))
        error ("categorical.cat: dimensions mismatch.");
      elseif (dim <= n_dims{1})
        idx = 1:n_dims{1};
        idx(dim) = [];
        rem_dims = cellfun (@(x) (x(idx)), sz_dim, "UniformOutput", false);
        if (! isequal (rem_dims{:}))
          error ("categorical.cat: size mismatch.");
        endif
      endif
      ## If any categorical value is ordinal, all must be
      is_ordinal = cellfun (@isordinal, args);
      if (all (is_ordinal))
        ## Check that all categorical arrays have the same categories
        ## and they are in the same order
        cats = cellfun (@(x) categories (x), args, 'UniformOutput', false);
        if (! isequal (cats{:}))
          error (strcat ("categorical.cat: cannot concatenate ordinal", ...
                         " categorical arrays unless they have the same", ...
                         " ordered set of categories."));
        endif
        out = args{1};
        fieldArgs = cellfun (@(x) x.code, args, 'UniformOutput', false);
        out.code = cat (dim, fieldArgs{:});
        fieldArgs = cellfun (@(x) x.isMissing, args, 'UniformOutput', false);
        out.isMissing = cat (dim, fieldArgs{:});
        return;
      elseif (any (is_ordinal))
        error (strcat ("categorical.cat: cannot concatenate ordinal", ...
                       " with non-ordinal categorical arrays."));
      endif
      ## If any categorical array is protected, all must have the same
      ## categories
      is_protected = cellfun (@isprotected, args);
      if (any (is_protected))
        ## Check that all categorical arrays have the same categories
        ## but they are not necessarily in the same order
        cats = cellfun (@(x) categories (x), args, 'UniformOutput', false);
        if (! all (ismember (cats{:})))
          error (strcat ("categorical.cat: cannot concatenate protected", ...
                         " categorical arrays that do not have the same", ...
                         " set of categories."));
        endif
        out = args{1};
        out.isProtected = true; # returning array must also be protected
        ## Go through remaining categorical arrays and reorder codes accordingly
        idx = cell (1, numel (cats{1}));
        for i = 2:numel (args)
          for j = 1:numel (out.cats)
            new_code = find (strcmp (args{i}.cats, out.cats(j)));
            idx{j} = args{i}.code == new_code;
          endfor
          for j = 1:numel (out.cats)
            args{i}.code(idx{j}) = j;
          endfor
        endfor
        fieldArgs = cellfun (@(x) x.code, args, 'UniformOutput', false);
        out.code = cat (dim, fieldArgs{:});
        fieldArgs = cellfun (@(x) x.isMissing, args, 'UniformOutput', false);
        out.isMissing = cat (dim, fieldArgs{:});
        return;
      endif
      ## No constrains, add new categories as necessary and bump code indexing
      ## to reflect the changes in category list of concatenated output array
      out = args{1};
      for i = 2:numel (args)
        n_cats = numel (categories (args{i}));
        idx = cell (2, n_cats);
        newcat = {};
        n_code = 0;
        for j = 1:n_cats
          new_code = find (strcmp (out.cats, args{i}.cats(j)));
          if (! isempty (new_code))
            idx{1,j} = args{i}.code == j;
            idx{2,j} = new_code;
          else
            idx{1,j} = args{i}.code == j;
            idx{2,j} = numel (out.cats) + n_code + 1;
            n_code += 1;
            newcat = [newcat; args{i}.cats(j)];
          endif
        endfor
        out.cats = [out.cats; newcat];
        for j = 1:n_cats
          args{i}.code(idx{1,j}) = idx{2,j};
        endfor
        fieldArgs = cellfun (@(x) x.code, args, 'UniformOutput', false);
        out.code = cat (dim, fieldArgs{:});
        fieldArgs = cellfun (@(x) x.isMissing, args, 'UniformOutput', false);
        out.isMissing = cat (dim, fieldArgs{:});
      endfor
      ## The categories of the first categorical input array need go first
      if (firstcatid > 1)
        catstostart = categories (args{firstcatid});
        catsremoved = out.cats(! ismember (out.cats, catstostart));
        catsreorder = [catstostart; catsremoved];
        out = reordercats (out, catsreorder);
      endif
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {categorical} {@var{C} =} horzcat (@var{A}, @var{B}, @dots{})
    ##
    ## Horizontal concatenation of categorical arrays.
    ##
    ## @code{@var{C} = horzcat (@var{A}, @var{B}, @dots{}} is the equivalent of
    ## the syntax @code{@var{B} = [@var{A}, @var{B}, @dots{}]} and horizontally
    ## concatenates the categorical arrays @var{A}, @var{B}, @dots{}.  All input
    ## arrays must have the same size except along the second dimension.  Any of
    ## the input arrays may also be string arrays or cell arrays of character
    ## vectors of compatible size.
    ##
    ## If any input array is an ordinal categorical array, then all inputs must
    ## be ordinal categorical arrays with the same set and ordering of
    ## categories.  In this case, @var{C} is also an ordinal categorical array
    ## with the same set and ordering of categories.  If none of the input
    ## arrays are ordinal, then they do not need to have the same set of
    ## categories.  In this case, categorical array @var{C} contains the union
    ## of the categories from all input arrays.  Protected categorical arrays
    ## can only be concatenated with other arrays that have the same set of
    ## categories but not necessarily in the same order.
    ##
    ## @end deftypefn
    function out = horzcat (varargin)
      out = cat (2, varargin{:});
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {categorical} {@var{C} =} vertcat (@var{A}, @var{B}, @dots{})
    ##
    ## Vertical concatenation of categorical arrays.
    ##
    ## @code{@var{C} = vertcat (@var{A}, @var{B}, @dots{}} is the equivalent of
    ## the syntax @code{@var{B} = [@var{A}; @var{B}; @dots{}]} and vertically
    ## concatenates the categorical arrays @var{A}, @var{B}, @dots{}.  All input
    ## arrays must have the same size except along the first dimension.  Any of
    ## the input arrays may also be string arrays or cell arrays of character
    ## vectors of compatible size.
    ##
    ## If any input array is an ordinal categorical array, then all inputs must
    ## be ordinal categorical arrays with the same set and ordering of
    ## categories.  In this case, @var{C} is also an ordinal categorical array
    ## with the same set and ordering of categories.  If none of the input
    ## arrays are ordinal, then they do not need to have the same set of
    ## categories.  In this case, categorical array @var{C} contains the union
    ## of the categories from all input arrays.  Protected categorical arrays
    ## can only be concatenated with other arrays that have the same set of
    ## categories but not necessarily in the same order.
    ##
    ## @end deftypefn
    function out = vertcat (varargin)
      out = cat (1, varargin{:});
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn  {categorical} {@var{B} =} repmat (@var{A}, @var{n})
    ## @deftypefnx {categorical} {@var{B} =} repmat (@var{A}, @var{d1}, @dots{}, @var{dN})
    ## @deftypefnx {categorical} {@var{B} =} repmat (@var{A}, @var{dimvec})
    ##
    ## Repeat copies of a categorical array.
    ##
    ## @code{@var{B} = repmat (@var{A}, @var{n})} returns a categorical array
    ## @var{B} containing @var{n} copies of the input categorical array @var{A}
    ## along every dimension of @var{A}.
    ##
    ## @code{@var{B} = repmat (@var{A}, @var{d1}, @dots{}, @var{dN})} returns an
    ## array @var{B} containing copies of @var{A} along the dimensions specified
    ## by the list of scalar integer values @var{d1}, @dots{}, @var{dN}, which
    ## specify how many copies of @var{A} are made in each dimension.
    ##
    ## @code{@var{B} = repmat (@var{A}, @var{dimvec})} is equivalent to the
    ## previous syntax with @code{@var{dimvec} = [@var{d1}, @dots{}, @var{dN}]}.
    ##
    ## @end deftypefn
    function this = repmat (this, varargin)
      this.code = repmat (this.code, varargin{:});
      this.isMissing = repmat (this.isMissing, varargin{:});
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn  {categorical} {@var{B} =} repelem (@var{A}, @var{n})
    ## @deftypefnx {categorical} {@var{B} =} repelem (@var{A}, @var{d1}, @dots{}, @var{dN})
    ##
    ## Repeat copies of categorical array elements.
    ##
    ## @code{@var{B} = repelem (@var{A}, @var{n})} returns a categorical vector
    ## @var{B} containing repeated elements of the input @var{A}, which must be
    ## a categorical vector.  If @var{n} is a scalar, each element of @var{A} is
    ## repeated @var{n} times along the non-singleton dimension of @var{A}.  If
    ## @var{n} is a vector, it must have the same elements as @var{A}, in which
    ## case it specifies the number of times to repeat each corresponding
    ## element of @var{A}.
    ##
    ## @code{@var{B} = repelem (@var{A}, @var{d1}, @dots{}, @var{dN})} returns
    ## an array @var{B} with each element of @var{A} repeated according to the
    ## the list of input arguments @code{@var{d1}, @dots{}, @var{dN}} each
    ## corresponding to a different dimension @code{1:ndims (@var{A})} of the
    ## input array @var{A}.  @var{d1}, @dots{}, @var{dN} must be either scalars
    ## or vectors with the same length as the corresponding dimension of
    ## @var{A} containing non-negative integer values specifying the number of
    ## repetitions of each element along the corresponding dimension.
    ##
    ## @end deftypefn
    function this = repelem (this, varargin)
      this.code = repelem (this.code, varargin{:});
      this.isMissing = repelem (this.isMissing, varargin{:});
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {categorical} {@var{B} =} repelems (@var{A}, @var{R})
    ##
    ## Construct a vector of repeated elements from a categorical array.
    ##
    ## @code{@var{B} = repelems (@var{A}, @var{R})} returns a categorical vector
    ## @var{B} containing repeated elements of the input @var{A}, which must be
    ## a categorical vector.  @var{R} must be a @math{2*N} matrix of integers.
    ## Entries in the first row of @var{R} correspond to the linear indexing of
    ## the elements in @var{A} to be repeated.  The corresponding entries in the
    ## second row of @var{R} specify the repeat count of each element.
    ##
    ## @end deftypefn
    function this = repelems (this, R)
      this.code = repelems (this.code, R);
      this.isMissing = repelems (this.isMissing, R);
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn  {categorical} {@var{B} =} reshape (@var{A}, @var{d1}, @dots{}, @var{dN})
    ## @deftypefnx {categorical} {@var{B} =} reshape (@var{A}, @dots{}, @qcode{[]}, @dots{})
    ## @deftypefnx {categorical} {@var{B} =} reshape (@var{A}, @var{dimvec})
    ##
    ## Repeat copies of categorical array elements.
    ##
    ## @code{@var{B} = reshape (@var{A}, @var{d1}, @dots{}, @var{dN})} returns a
    ## categorical array @var{B} with specified dimensions @var{d1}, @dots{},
    ## @var{dN}, whose elements are taken columnwise from the categorical array
    ## @var{A}.  The product of @var{d1}, @dots{}, @var{dN} must equal the total
    ## number of elements in @var{A}.
    ##
    ## @code{@var{B} = reshape (@var{A}, @dots{}, @qcode{[]}, @dots{})} returns
    ## a categorical array @var{B} with one dimension unspecified which is
    ## calculated automatically so that the product of dimensions in @var{B}
    ## matches the total elements in @var{A}, which must be divisible the
    ## product of specified dimensions.  An empty matrix @qcode{([])} is used to
    ## flag the unspecified dimension.
    ##
    ## @end deftypefn
    function this = reshape (this, varargin)
      this.code = reshape (this.code, varargin{:});
      this.isMissing = reshape (this.isMissing, varargin{:});
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn  {categorical} {@var{B} =} circshift (@var{A}, @var{n})
    ## @deftypefnx {categorical} {@var{B} =} circshift (@var{A}, @var{n}, @var{dim})
    ##
    ## Circularly shift the elements in a categorical array.
    ##
    ## @code{@var{B} = circshift (@var{A}, @var{n})} circularly shifts the
    ## elements of the categorical array @var{A} according to @var{n}.  If
    ## @var{n} is a nonzero integer scalar, then the elements of @var{A} are
    ## shifted by @var{n} elements along the first non-singleton dimension of
    ## @var{A}.  If @var{n} is a vector, it must not be longer that the number
    ## of dimensions of @var{A} with each value of @var{n} corresponding to a
    ## dimension in @var{A}.   The sign of the value(s) in @var{n} specify the
    ## direction in the elements of @var{A} are shifted.
    ##
    ## @code{@var{B} = circshift (@var{A}, @var{n}, @var{dim})} circularly
    ## shifts the elements of the categorical array @var{A} along the dimension
    ## specified by @var{dim}.  In this case, @var{n} must be a scalar integer
    ## value.
    ##
    ## @end deftypefn
    function this = circshift (this, varargin)
      this.code = circshift (this.code, varargin{:});
      this.isMissing = circshift (this.isMissing, varargin{:});
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {categorical} {@var{B} =} permute (@var{A}, @var{dims})
    ##
    ## Generalized transpose for a categorical N-D array.
    ##
    ## @code{@var{B} = permute (@var{A}, @var{dims})} returns the generalized
    ## transpose of the categorical array @var{A} by rearranging its dimensions
    ## according to the permutation vector specified in @var{dims}.
    ##
    ## @var{dims} must index all the dimensions @code{1:ndims (@var{A})} of the
    ## input array @var{A}, in any order, but only once.  The @var{N}th
    ## dimension of @var{A} gets remapped to the dimension in @var{B} specified
    ## by @code{@var{dims}(@var{N})}.
    ##
    ## @end deftypefn
    function this = permute (this, dims)
      this.code = permute (this.code, dims);
      this.isMissing = permute (this.isMissing, dims);
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {categorical} {@var{A} =} ipermute (@var{B}, @var{dims})
    ##
    ## Inverse of the generalized transpose for a categorical N-D array.
    ##
    ## @code{@var{A} = ipermute (@var{B}, @var{dims})} returns the inverse of
    ## the generalized transpose performed by the @code{permute} function.  The
    ## expression @code{ipermute (permute (@var{A}, @var{dims}), @var{dims})}
    ## returns the original array @var{A}.
    ##
    ## @var{dims} must index all the dimensions @code{1:ndims (@var{B})} of the
    ## input array @var{B}, in any order, but only once.  The dimension of
    ## @var{B} specified in @code{@var{dims}(@var{N})} gets remapped to the
    ## @var{N}th dimension of @var{A}.
    ##
    ## @end deftypefn
    function this = ipermute (this, dims)
      this.code = ipermute (this.code, dims);
      this.isMissing = ipermute (this.isMissing, dims);
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {categorical} {@var{B} =} transpose (@var{A})
    ##
    ## Transpose a categorical matrix.
    ##
    ## @code{@var{B} = transpose (@var{A})} is the equivalent of the syntax
    ## @code{@var{B} = @var{A}.'} and returns the transpose of the categorical
    ## matrix @var{A}.
    ##
    ## @end deftypefn
    function this = transpose (this)
      if (ndims (this) != 2)
        error ("categorical.transpose: not defined for N-D arrays.");
      endif
      this.code = transpose (this.code);
      this.isMissing = transpose (this.isMissing);
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {categorical} {@var{B} =} ctranspose (@var{A})
    ##
    ## Transpose a categorical matrix.
    ##
    ## @code{@var{B} = ctranspose (@var{A})} is the equivalent of the syntax
    ## @code{@var{B} = @var{A}'} and returns the transpose of the categorical
    ## matrix @var{A}.  For categorical arrays, @code{ctranspose} is identical
    ## to @code{transpose}.
    ##
    ## @end deftypefn
    function this = ctranspose (this)
      if (ndims (this) != 2)
        error ("categorical.ctranspose: not defined for N-D arrays.");
      endif
      this.code = ctranspose (this.code);
      this.isMissing = ctranspose (this.isMissing);
    endfunction

  endmethods

################################################################################
##                  ** Reference and Assignment Operations **                 ##
################################################################################
##                             Available Methods                              ##
##                                                                            ##
## 'end'              'subsref'          'subsasgn'                           ##
##                                                                            ##
################################################################################

  methods (Hidden)

    ## Overload 'end' keyword
    function last_index = end (this, end_dim, ndim_obj)
      lastdim = ndims (this);
      if (end_dim == ndim_obj && ndim_obj == 1)
        last_index = prod (size (this));
      elseif (end_dim == ndim_obj && end_dim < lastdim)
        last_index = prod (size (this)(end_dim:lastdim));
      else
        last_index = size (this, end_dim);
      endif
    endfunction

    ## Class specific subscripted reference
    function varargout = subsref (this, s)

      chain_s = s(2:end);
      s = s(1);
      switch (s.type)
        case '()'
          out = this;
          out.code = this.code(s.subs{:});
          out.isMissing = this.isMissing(s.subs{:});

        case '{}'
          error (strcat ("categorical.subsref: '{}' invalid indexing", ...
                         " for referencing values. Use '()' instead."));

        case '.'
          error (strcat ("categorical.subsref: '.' invalid indexing", ...
                         " for referencing field of non-structure array."));
      endswitch

      ## Chained references
      if (! isempty (chain_s))
        out = subsref (out, chain_s);
      endif
      varargout{1} = out;

    endfunction

    ## Class specific subscripted assignment
    function this = subsasgn (this, s, val)

      if (numel (s) > 1)
        error ("categorical.subsasgn: chained subscripts not allowed.");
      endif
      switch s.type
        case '()'
          if (isempty (val) && isa (val, 'double'))
            this.code(s.subs{:}) = [];
            this.isMissing(s.subs{:}) = [];
            return;
          elseif (isa (val, 'missing'))
            this.code(s.subs{:}) = 0;
            this.isMissing(s.subs{:}) = true;
            return;
          elseif (iscellstr (val) || ischar (val) || isstring (val))
            ## Convert to unprotected unordered categorical array and
            ## handle it as usual below
            if (ischar (val))
              val = cellstr (val);
            elseif (isempty (val))
              error ("categorical.subsasgn: assignment value has no elements.");
            endif
            val = categorical (val);
          elseif (! isa (val, 'categorical'))
            error (strcat ("categorical.subsasgn: assignment value must", ...
                           " be a categorical array or text representing", ...
                           " a single or multiple categories."));
          endif
          ## After this point VAL is a categorical array
          ## If any categorical array is ordinal, all must be
          if (isordinal (this) && isordinal (val))
            ## Check that all categorical arrays have the same categories
            ## and they are in the same order
            if (! isequal (categories (this), categories (val)))
              error (strcat ("categorical.subsasgn: cannot assign value", ...
                             " to ordinal categorical array unless they", ...
                             " have the same ordered set of categories."));
            endif
            this.code(s.subs{:}) = val.code;
            this.isMissing(s.subs{:}) = val.isMissing;
            return;
          elseif (isordinal (this))
            error (strcat ("categorical.subsasgn: cannot assign unordered", ...
                           " categorical array to ordinal categorical array."));
          elseif (isordinal (val))
            error (strcat ("categorical.subsasgn: cannot assign ordinal", ...
                           " categorical array to unordered categorical", ...
                           " array."));
          endif
          ## If protected, no new categories can be assigned
          if (isprotected (this))
            if (! all (ismember (val.cats, this.cats)))
              error (strcat ("categorical.subsasgn: cannot assign new", ...
                             " categories to protected categorical array."));
            endif
            ## Reorder codes accordingly
            n_cats = numel (val.cats);
            idx = cell (2, n_cats);
            for j = 1:n_cats
              new_code = find (strcmp (this.cats, val.cats(j)));
              idx{1,j} = val.code == j;
              idx{2,j} = new_code;
            endfor
            for j = 1:n_cats
              val.code(idx{1,j}) = idx{2,j};
            endfor
            this.code(s.subs{:}) = val.code;
            this.isMissing(s.subs{:}) = val.isMissing;
            return;
          endif
          ## No constrains, add new categories as necessary and bump code
          ## indexing to reflect the changes in category list of assigned
          ## categorical array
          n_cats = numel (val.cats);
          maxcat = numel (this.cats);
          idx = cell (2, n_cats);
          newcat = {};
          n_code = 0;
          for j = 1:n_cats
            new_code = find (strcmp (this.cats, val.cats(j)));
            if (! isempty (new_code))
              idx{1,j} = val.code == j;
              idx{2,j} = new_code;
            else
              idx{1,j} = val.code == j;
              idx{2,j} = maxcat + n_code + 1;
              n_code += 1;
              newcat = [newcat; val.cats(j)];
            endif
          endfor
          this.cats = [this.cats; newcat];
          for j = 1:n_cats
            val.code(idx{1,j}) = idx{2,j};
          endfor
          this.code(s.subs{:}) = val.code;
          this.isMissing(s.subs{:}) = val.isMissing;

        case '{}'
          error (strcat ("categorical.subsasgn: '{}' invalid indexing", ...
                         " for assigning values. Use '()' instead."));

        case '.'
          error (strcat ("categorical.subsasgn: '.' invalid indexing", ...
                         " for assigning field of non-structure array."));
      endswitch

    endfunction

  endmethods

  methods (Access = private)

    ## Return a subset of the array
    function this = subset (this, varargin)
      this = this;
      this.code = this.code(varargin{:});
      this.isMissing = this.isMissing(varargin{:});
    endfunction

    ## Common function for set operations
    function [C, ixA, ixB] = setop (A, B, fname, varargin)
      ixA = ixB = [];
      ## 'legacy' option is not supported
      if (any (strcmp ("legacy", varargin)))
        C = sprintf (strcat ("categorical.%s: 'legacy'", ...
                             " option is not supported."), fname);
        return;
      endif
      if (ischar (A))
        A = categorical (cellstr (A));
      elseif (iscellstr (A) || isa (A, 'string'))
        A = categorical (A);
      elseif (ischar (B))
        B = categorical (cellstr (B));
      elseif (iscellstr (B) || isa (B, 'string'))
        B = categorical (B);
      endif
      if (! isa (A, 'categorical') || ! isa (B, 'categorical'))
        C = sprintf ("categorical.%s: invalid type input arrays.", fname);
        return;
      endif
      if (xor (A.isOrdinal, B.isOrdinal))
        C = sprintf (strcat ("categorical.%s: if A is", ...
                             " ordinal, B must be ordinal."), fname);
        return;
      endif
      C = A;
      if (A.isOrdinal && B.isOrdinal)
        if (! isequal (A.cats, B.cats))
          C = sprintf (strcat ("categorical.%s: ordinal arrays must", ...
                               " have the same set of categories in", ...
                               " the same order."), fname);
          return;
        endif
        ## For ordinal arrays, operate directly on codes
        Acodes = double (A);
        Bcodes = double (B);
      else
        ## For unordered arrays, operate on categorical values
        allcats = unique ([A.cats(:); B.cats(:)]);
        A_cats_idx = cellfun (@(x) find (ismember (allcats, x)), A.cats);
        Acodes = nan (size (A.code));
        for i = 1:numel (A_cats_idx)
          Acodes(A.code == i) = A_cats_idx(i);
        endfor
        B_cats_idx = cellfun (@(x) find (ismember (allcats, x)), B.cats);
        Bcodes = nan (size (B.code));
        for i = 1:numel (B_cats_idx)
          Bcodes(B.code == i) = B_cats_idx(i);
        endfor
        C.cats = allcats;
      endif
      ## Apply set operation
      switch (fname)
        case 'intersect'
          [code, ixA, ixB] = intersect (Acodes, Bcodes, varargin{:});
        case 'setdiff'
          [code, ixA] = setdiff (Acodes, Bcodes, varargin{:});
        case 'setxor'
          [code, ixA, ixB] = setxor (Acodes, Bcodes, varargin{:});
        case 'union'
          [code, ixA, ixB] = union (Acodes, Bcodes, varargin{:});
      endswitch
      ## Add codes and missing arrays
      C.code = uint16 (code);
      C.isMissing = C.code == 0;
    endfunction

    ## Expand categorical arrays of compatible sizes
    function [A, B, err] = csize_expand (A, B)
      err = false;
      szA = size (A);
      szB = size (B);
      if (isscalar (A) && ! isscalar (B))
        A = repmat (A, szB);
      elseif (! isscalar (A) && isscalar (B))
        B = repmat (B, szA);
      elseif (! isequal (szA, szB))
        ndA = numel (szA);
        ndB = numel (szB);
        if (ndA > ndB)
          padones = ones (1, ndA - ndB);
          szB = [szB, padones];
        elseif (ndA < ndB)
          padones = ones (1, ndB - ndA);
          szA = [szA, padones];
        endif
        ## Check that sizes are compatible
        must_equal = szA != 1 & szB != 1;
        if (! isequal (szA(must_equal), szB(must_equal)))
          err = true;
          return;
        endif
        ## Create expansion vectors
        dims = max (ndA, ndB);
        repA = ones (1, dims);
        repB = ones (1, dims);
        ## Get dimensions that should be expanded
        expA = szA == 1 & szB != 1;
        repA(expA) = szB(expA);
        expB = szB == 1 & szA != 1;
        repB(expB) = szA(expB);
        ## Expand accordingly
        A = repmat (A, repA);
        B = repmat (B, repB);
      endif
    endfunction

  endmethods

endclassdef

## Promote text arrays to categorical objects
function varargout = promote (varargin)
  for i = 1:numel (varargin)
    val = varargin{i};
    if (isa (val, "categorical"))
      varargout{i} = val;
    elseif (iscellstr (val) || isa (val, "string") || ischar (val))
      val = cellstr (val);
      varargout{i} = categorical (val);
    elseif (isnumeric (val) || islogical (val))
      varargout{i} = categorical (val);
    elseif (isa (val, 'missing'))
      ## Shape from VAL, not from a fresh 'missing': the latter is always 1x1
      ## and silently collapsed every non-scalar missing operand.
      varargout{i} = categorical (nan (size (val)));
    else
      error ("categorical: invalid input to constructor.");
    endif
  endfor
endfunction

## Custom function for displaying categorical summary
function dispcellmatrix (C)
  sz = terminal_size ();
  cols = sz(2) - 4;
  colgap = "     ";
  dispstr = {};
  optLens = [];
  for iCol = 1:size (C, 2)
    [outstr, optLen] = mixedcell2str (C(:, iCol));
    dispstr = [dispstr, outstr];
    optLens = [optLens, optLen];
  endfor
  if (sum (optLens + 6) <= cols) # all columns fit in terminal size
    rowSpat = "";
    for iCol = 1:size (C, 2)
      rowSpat = [rowSpat, sprintf("%%-%ds", optLens(iCol)), colgap];
    endfor
    for iRow = 1:size (C, 1)
      strrow = sprintf (rowSpat, dispstr{iRow,:});
      fprintf ("    %s\n", strrow);
    endfor
    fprintf ("\n");
  else  # we need to split rows
    optLen_cs = cumsum (optLens + 6);
    startCol = 1;
    while (! isempty (find (optLen_cs > cols)))
      stopCol = find (optLen_cs > cols, 1) - 1;
      rowSpat = "";
      for iCol = 1:stopCol
        rowSpat = [rowSpat, sprintf("%%-%ds", optLens(iCol)), colgap];
      endfor
      optLens(1:iCol) = [];
      optLen_cs = cumsum (optLens + 6);
      stopCol = stopCol + startCol - 1;
      fprintf ("Columns %d through %d:\n\n", startCol, stopCol);
      for iRow = 1:size (C, 1)
        strrow = sprintf (rowSpat, dispstr{iRow,[startCol:stopCol]});
        fprintf ("    %s\n", strrow);
      endfor
      fprintf ("\n");
      startCol = stopCol + 1;
    endwhile
    if (! isempty (optLens))
      for iCol = 1:length (optLens)
        rowSpat = [rowSpat, sprintf("%%-%ds", optLens(iCol)), colgap];
      endfor
      stopCol = startCol + iCol - 1;
      if (startCol == stopCol)
        fprintf ("Column %d:\n\n", startCol);
      else
        fprintf ("Columns %d through %d:\n\n", startCol, stopCol);
      endif
      for iRow = 1:size (C, 1)
        strrow = sprintf (rowSpat, dispstr{iRow,[startCol:stopCol]});
        fprintf ("    %s\n", strrow);
      endfor
      fprintf ("\n");
    endif
  endif
endfunction

## Custom function to convert a mixed cell array to cellstr array
function [dispstr, optLen]  = mixedcell2str (data)
  dispstr = cell (size (data));
  ## Preallocate indexes to avoid truncation when last elements are 0
  is_char = logical (zeros (size (data)));
  is_numb = is_char;

  ## Index numeric scalar and character row vector
  ve = cell2mat (cellfun (@(x) size (x,1), data, "UniformOutput", false)) == 1;

  ## Catch 'char' row vectors
  is_char(ve) = cellfun ('ischar', data(ve));
  sf = @(x) sprintf ("%s", x);
  dispstr(is_char) = cellfun (sf, data(is_char), "UniformOutput", false);

  ## Catch 'numeric' scalars
  is_numb(ve) = cellfun ('isnumeric', data(ve));
  sf = @(x) sprintf ("%d", x);
  dispstr(is_numb) = cellfun (sf, data(is_numb), "UniformOutput", false);

  ## Get optimal length
  optLen = max (cellfun (@length, dispstr));

  ## Pad data according to optimal length
  Ra_wB = sprintf ("%%-%ds", optLen);
  fcn = @(x) sprintf (Ra_wB, x);
  dispstr(is_numb) = cellfun (fcn, dispstr(is_numb), "UniformOutput", false);

  La_wB = sprintf ("%%+%ds", optLen);
  fcn = @(x) sprintf (La_wB, x);
  dispstr(is_char) = cellfun (fcn, dispstr(is_char), "UniformOutput", false);
endfunction
