tool to ensure that X:
    - is 2 dimensional
    - contains float-compatible data-types
    - has at least min_samples
    - has n_feats
    - has categorical features in the right range
    - is finite

    Parameters
    ----------
    X : array-like
    n_feats : int. default: None
              represents number of features that X should have.
              not enforced if n_feats is None.
    min_samples : int, default: 1
    edge_knots : list of arrays, default: None
    dtypes : list of strings, default: None
    features : list of ints,
        which features are considered by the model
    verbose : bool, default: True
        whether to print warnings

    Returns
    -------
    X : array with ndims == 2 containing validated X-data

def check_X(X, n_feats=None, min_samples=1, edge_knots=None, dtypes=None,
            features=None, verbose=True):
    """
    tool to ensure that X:
    - is 2 dimensional
    - contains float-compatible data-types
    - has at least min_samples
    - has n_feats
    - has categorical features in the right range
    - is finite

    Parameters
    ----------
    X : array-like
    n_feats : int. default: None
              represents number of features that X should have.
              not enforced if n_feats is None.
    min_samples : int, default: 1
    edge_knots : list of arrays, default: None
    dtypes : list of strings, default: None
    features : list of ints,
        which features are considered by the model
    verbose : bool, default: True
        whether to print warnings

    Returns
    -------
    X : array with ndims == 2 containing validated X-data
    """
    # check all features are there
    if bool(features):
        features = flatten(features)
        max_feat = max(flatten(features))

        if n_feats is None:
            n_feats = max_feat

        n_feats = max(n_feats, max_feat)

    # basic diagnostics
    X = check_array(X, force_2d=True, n_feats=n_feats, min_samples=min_samples,
                    name='X data', verbose=verbose)

    # check our categorical data has no new categories
    if (edge_knots is not None) and (dtypes is not None) and (features is not None):

        # get a flattened list of tuples
        edge_knots = flatten(edge_knots)[::-1]
        dtypes = flatten(dtypes)
        assert len(edge_knots) % 2 == 0 # sanity check

        # form pairs
        n = len(edge_knots) // 2
        edge_knots = [(edge_knots.pop(), edge_knots.pop()) for _ in range(n)]

        # check each categorical term
        for i, ek in enumerate(edge_knots):
            dt = dtypes[i]
            feature = features[i]
            x = X[:, feature]

            if dt == 'categorical':
                min_ = ek[0]
                max_ = ek[-1]
                if (np.unique(x) < min_).any() or \
                   (np.unique(x) > max_).any():
                    min_ += .5
                    max_ -= 0.5
                    raise ValueError('X data is out of domain for categorical '\
                                     'feature {}. Expected data on [{}, {}], '\
                                     'but found data on [{}, {}]'\
                                     .format(i, min_, max_, x.min(), x.max()))

    return X