Introduction to the *smurf* package"

library("smurf")

Introduction

The smurf package contains the implementation of the Sparse Multi-Type Regularized Feature modeling (SMuRF) algorithm for Generalized Linear Models (GLMs): a proximal gradient algorithm to fit GLMs with multiple types of predictors via regularized maximum likelihood. This vignette describes how to use the most important functions of the package.

Data

All functions will be illustrated using the rent data from the catdata package which contains the rent prices for Munich residences in 2003. The goal is to predict the monthly rent per square meter, based on a set of predictors. Since this data was studied previously by @gertheiss2010, the predictors are pretreated in the same way.

data("rent", package = "catdata")

# Urban district in Munich
rent$area <- as.factor(rent$area)

# Decade of construction
rent$year <- as.factor(floor(rent$year / 10) * 10)

# Number of rooms
rent$rooms <- as.factor(rent$rooms)

# Quality of the house with levels "fair", "good" and "excellent"
rent$quality <- as.factor(rent$good + 2 * rent$best)
levels(rent$quality) <- c("fair", "good", "excellent")

# Floor space divided in categories (0, 30), [30, 40), ...,  [130, 140)
sizeClasses <- c(0, seq(30, 140, 10))
rent$size <- as.factor(sizeClasses[findInterval(rent$size, sizeClasses)])

# Is warm water present?
rent$warm <- factor(rent$warm, labels = c("yes", "no"))

# Is central heating present?
rent$central <- factor(rent$central, labels = c("yes", "no"))

# Does the bathroom have tiles?
rent$tiles <- factor(rent$tiles, labels = c("yes", "no"))

# Is there special furniture in the bathroom?
rent$bathextra <- factor(rent$bathextra, labels = c("no", "yes"))

# Is the kitchen well-equipped?
rent$kitchen <- factor(rent$kitchen, labels = c("no", "yes"))

Fitting a model

Consider a response variable $\mathbf y$ and the model matrix $\boldsymbol X$. The objective function for a regularized generalized linear model with a multi-type penalty is \begin{equation} \mathcal{O}(\boldsymbol\beta; \boldsymbol X, \mathbf y) = f(\boldsymbol\beta; \boldsymbol X,\mathbf y) + \lambda \cdot \sum_{j=0}^J g_j(\boldsymbol\beta_j), (#eq:penmultireg) \end{equation} where $f(\cdot)$ is minus the log-likelihood function divided by the sample size, $g_j(\cdot)$ a convex function for all $j \in {0,\ldots, J}$ and $\boldsymbol\beta_j$ represents a subset of the full parameter vector $\boldsymbol\beta$ such that $(\beta_0, \boldsymbol\beta_1,\ldots, \boldsymbol\beta_J) = \boldsymbol\beta$, with $\beta_0$ the intercept. As the intercept is usually not regularized, we set $g_0(\cdot) = 0$. The penalty functions $g_j(\cdot)$ serve as a measure to avoid overfitting the data, while the tuning parameter $\lambda$ controls the strength of the penalty. A high value of $\lambda$ increases its importance in the objective function $\mathcal{O}(\cdot)$ and will increase the sparsity of the estimated model. More details can be found in @SMuRF.

Formula

Using a formula object, the user supplies the partition of $\boldsymbol\beta$ in subvectors $\boldsymbol\beta_j$ and the choice of $g_j(\cdot)$ such that for each $j$ the penalty $g_j(\cdot)$ takes the underlying structure of the coefficients in $\boldsymbol\beta_j$ into account. In principle, each $\boldsymbol\beta_j$ corresponds to a single predictor or an interaction effect. Only for the Group Lasso penalty, multiple predictors can be combined into one $\boldsymbol\beta_j$, see Group Lasso.

The response variable is added to the formula with its name followed by a tilde as in the formula for the standard GLM function in R. Predictors are added with their name using the p function. This function contains a pen argument which indicates the penalty type:

where "lasso" is the default. If a predictor is added to the formula without the p function, this predictor is not regularized, i.e. this is equivalent to using p with argument pen = "none", see bathextra in the example below.

When a predictor is given as a factor, there cannot be any unused levels. For a factor, the first level is taken as the reference category in case one is required (see Fused Lasso and Generalized Fused Lasso). Using the refcat argument in p, the user can specify a different level to be used as the reference category. In the example below, the reference category for year is changed from the first level (1910) to 2000.

Note that all predictors need to be contained in a data frame that is specified in the fitting function glmsmurf.

As an example we create a formula with

rentm ~ p(area, pen = "gflasso") + 
        p(year, pen = "flasso", refcat = 2000) + p(rooms, pen = "flasso") + 
        p(quality, pen = "flasso") + p(size, pen = "flasso") +
        p(warm, pen = "grouplasso", group = 1) + p(central, pen = "grouplasso", group = 1) + 
        p(tiles, pen = "none") + bathextra + 
        p(kitchen, pen = "lasso")

Penalty types

In this subsection we explain the different subpenalty types of the multi-type Lasso penalty.

Lasso

The Lasso penalty is particularly useful for categorical or continuous predictors, e.g. the kitchen predictor in the rent data, and is given by \begin{equation} g_{\text{Lasso}}(\boldsymbol\beta_j) = \sum_{i=1}^{p_j} w_{j,i}|\beta_{j,i}| = ||\mathbf w_j * \boldsymbol\beta_j||1, (#eq:lasso) \end{equation} where $p_j$ is the number of individual coefficients $\beta{j,i}$ within the vector $\boldsymbol\beta_j$, $\mathbf w_j$ is a vector of penalty weights and `$*$' denotes the componentwise multiplication. Depending on the tuning parameter $\lambda$ and the weight vector $\mathbf w_j$, this penalty will encourage some coefficients to become zero, effectively removing them from the model. The other coefficients will have estimates closer to 0 than in an unregularized setting, reducing estimation variance but increasing bias. For continuous predictors represented by one coefficient, the Lasso penalty serves as a feature selection tool where the most important predictors receive non-zero coefficients. With categorical predictors, Lasso selects the relevant coefficients (or: levels) within each predictor.

No reference category should be chosen, as this would change the interpretation of the coefficients and subsequently of the Lasso penalty.

Group Lasso

The Group Lasso penalty uses an $L_2$-norm to encourage the coefficients in $\boldsymbol\beta_j$ to be removed from the model: \begin{equation} g_{\text{grpLasso}}(\boldsymbol\beta_j) = w_j \sqrt{\sum_{i=1}^{p_j} \beta_{j,i}^2}= ||w_{j}\boldsymbol\beta_{j}||_2, \end{equation} where $w_{j}$ is the penalty weight for predictor $j$. In contrast to the $L_1$-norm, the $L_2$-norm is not separable for each coefficient in $\boldsymbol\beta_j$ and is only non-differentiable when all $\beta_{j,i}$ are 0. When $\boldsymbol\beta_j$ consists of only one coefficient, the $L_2$-norm reduces to the $L_1$-norm and the standard Lasso penalty is retrieved. The Group Lasso penalty is appropriate to test if $\boldsymbol\beta_j$ has adequate predictive power as a whole, because the estimates for $\beta_{j,i}$ will be either all zero or all non-zero. This is particularly useful for selecting categorical factors.

When applied to a categorical predictor, the Group Lasso requires no reference category, similar to the case of the standard Lasso penalty.

By default, the Group Lasso penalty is applied to all coefficients of a single predictor. However, using the group argument in the p function, one can also set a Group Lasso penalty to the coefficients of multiple predictors. In the example above, the Group Lasso penalty is applied to the coefficients of both the predictors warm and central (which form group 1). This means that the $\boldsymbol\beta_j$ is then the vector containing the coefficients of warm and central. Note that group = 0 means that this predictor does not belong to a group, i.e. $\boldsymbol\beta_j$ contains the coefficients of only this predictor.

Fused Lasso

To group, or bin, consecutive levels within a predictor, the Fused Lasso penalty puts an $L_1$-penalty on the differences between subsequent coefficients: \begin{equation} g_{\text{fLasso}}(\boldsymbol\beta_j) = \sum_{i=2}^{p_j} w_{j,i-1}|\beta_{j,i} - \beta_{j,i-1}| = ||D(\mathbf w_j) \boldsymbol\beta_j||_1, (#eq:flasso) \end{equation} with $D(\mathbf w_j)$ the first order difference matrix where the rows are weighted by the elements in $\mathbf w_j$: \begin{equation} D(\mathbf w_j) = \begin{bmatrix} -w_{j,1} & w_{j,1} & 0 & & 0 & 0\ 0& -w_{j,2} & w_{j,2} & \cdots & 0& 0\ 0& 0 & -w_{j,3} & & 0 &0\ & \vdots & & \ddots & w_{j,p_j-2} & 0\ 0& 0&0&& -w_{j,p_j-1} & w_{j,p_j-1} \end{bmatrix}. (#eq:Dmat) \end{equation} This penalty is suitable for ordinal predictors and continuous predictors coded as ordinal predictors to capture non-linear effects. Because \@ref(eq:flasso) only regularizes differences, a reference level needs to be chosen to get a unique minimizer $\boldsymbol\beta$ when used in optimization problem \@ref(eq:penmultireg). The coefficient of $\boldsymbol\beta_j$ corresponding to this reference level is then set to 0 or, equivalently, omitted from the vector $\boldsymbol\beta_j$ as well as the relevant column in \@ref(eq:Dmat). For high values of $\lambda$ in \@ref(eq:penmultireg), all coefficients in $\boldsymbol\beta_j$ will become 0, such that they are fused with the reference category, and the corresponding predictor is then effectively removed from the model.

When using a Fused Lasso penalty, the levels should be ordered from smallest to largest. By default, the first level will be the reference level, but this can be changed using the refcat argument (see above).

Generalized Fused Lasso

The Generalized Fused Lasso penalty allows the user to set a graph $\mathcal{G}$ to determine which coefficient differences should be regularized: \begin{align} g_{\text{gfLasso}}(\boldsymbol\beta_j) &= \sum_{(i,l)\in \mathcal{G}} w_{j,il}|\beta_{j,i} - \beta_{j,l}| = ||G(\mathbf w_j)\boldsymbol\beta_j||_1,(#eq:gflasso) \end{align} where $G(\mathbf w_j)$ is the matrix of the linear map projecting $\boldsymbol\beta_j$ onto all differences of coefficients connected by edges in the graph $\mathcal{G}$, with the rows weighted by the elements in $\mathbf w_j$. The matrix $G(\mathbf w_j)$ thus generalizes $D(\mathbf w_j)$ in \@ref(eq:Dmat). Similar to the Fused Lasso, a reference category is needed to obtain a unique minimizer $\boldsymbol\beta$ of \@ref(eq:penmultireg). This penalty is useful to bin predictors whenever a straightforward graph is available.

Combined penalty

We allow for combinations of the Lasso and the Group Lasso with the (Generalized) Fused Lasso penalty such that a joint penalty for $\boldsymbol\beta_j$ results: \begin{equation} g_{\text{s.grp.gfLasso}}(\boldsymbol\beta_j) = \lambda_1 g_{\text{Lasso}}(\boldsymbol\beta_j) + \lambda_2 g_{\text{grpLasso}}(\boldsymbol\beta_j) + g_{\text{gfLasso}}(\boldsymbol\beta_j). (#eq:sgrpgfl) \end{equation} We refer to this penalty as the Sparse Group Generalized Fused Lasso for which tuning parameters $\lambda_1$ and $\lambda_2$ determine the relative strength of each term in the joint penalty. Adding the Lasso penalty to the (Generalized) Fused Lasso allows for simultaneous selection and binning of the individual coefficients. The Group Lasso encourages selection of the vector $\boldsymbol\beta_j$ on top of the binning effect of (Generalized) Fused Lasso. Due to the addition of the Lasso or Group Lasso penalty, the parametrization in \@ref(eq:sgrpgfl) is uniquely determined when $\lambda_1$ or $\lambda_2$ is non-zero and no reference category is needed.

lambda1 and lambda2 are input arguments for the fitting function glmsmurf. They are by default equal to zero meaning that the ordinary (Generalized) Fused Lasso penalty is used.

Fitting function

The glmsmurf function fits a multi-type regularized GLM using the SMuRF algorithm. Following arguments need to be provided:

or a list with the penalty weight vector per predictor. We refer to @SMuRF for more details on standardization and adaptive penalty weights per penalty type.

For the Munich rent example, we fit a GLM similar to @gertheiss2010. First, we create a formula with rentm as response variable, area with a Generalized Fused Lasso penalty to regularize all possible differences between the coefficients of the areas, year, rooms, quality and size with Fused Lasso penalties to regularize the difference between subsequent coefficients, and the other (binary) predictors are regularized using Lasso.

formu <- rentm ~ p(area, pen = "gflasso") + 
                 p(year, pen = "flasso") + p(rooms, pen = "flasso") + 
                 p(quality, pen = "flasso") + p(size, pen = "flasso") +
                 p(warm, pen = "lasso") + p(central, pen = "lasso") + 
                 p(tiles, pen = "lasso") + p(bathextra, pen = "lasso") + 
                 p(kitchen, pen = "lasso") 

Next, we fit a multi-type regularized GLM, where we use standardization adaptive penalty weights based on an initial GLM fit. We predetermined the value for lambda using cross-validation (with the deviance as loss measure and the one standard error rule), see Selection of lambda.

munich.fit <- glmsmurf(formula = formu, family = gaussian(), data = rent, 
                       pen.weights = "glm.stand", lambda = 0.01404071)

Output

The glmsmurf function returns an object of the S3 class glmsmurf which partially inherits from the glm and lm classes. It contains, among others, the coefficients of the fitted model, and the deviance and information criteria of the fitted model. An overview of all components of a glmsmurf-object can be found on its help page.

There are several S3 methods available for objects of this class. Below we illustrate a few of these methods, but a full overview can be found on the help page of glmsmurf-objects.

As with most regularization methods, the coefficient estimates and predictions of our fitted model will be biased. To reduce this bias, we propose to re-estimate the model without penalties, but with a reduced model matrix $\boldsymbol X'$, based on the parameter estimates. This can be done by removing the columns of $\boldsymbol X$ for which the coefficients are estimated to be 0 (feature selection), and by collapsing the columns for which the coefficient estimates are fused (clustering). The re-estimation is then performed by optimizing the objective function without the penalties, but on the reduced model matrix $\boldsymbol X'$. The re-estimated coefficients will thus have the same non-zero and fused coefficients as the regularized coefficients, but will not be biased.

We first plot the coefficients of the estimated model (first plot), and the coefficients of the re-estimated model (second plot). The grey squares indicate zero coefficients. Per predictor, groups of equal coefficients are indicated in the same color (up to 8 colors).

plot(munich.fit)
plot_reest(munich.fit)

Next, it is also useful to look at the summary function. It prints the coefficients of the estimated and re-estimated models, next to information on the goodness-of-fit and some details on the SMuRF algorithm (including the number of iterations).

summary(munich.fit)

We see e.g. that buildings from the 1930s and 1940s form a cluster, and similar for buildings of the 1990s and the 2000s. The coefficient for 'two rooms' is 0 which means that the effect for a one-room (reference category) and a two-room appartment is the same. As expected, the re-estimated coefficients are very similar to the results from @gertheiss2010.

Selection of lambda

The penalty parameter $\lambda$ can be given as input by the user, or determined using an in-sample or out-of-sample criterion or using (stratified) $k$-fold cross-validation. This is specified through the input argument lambda.

When no numeric value of $\lambda$ is given, the algorithm considers a vector $\boldsymbol{\lambda}$ of length $n_\lambda$, given by lambda.length, with exponentially decreasing values between lambda.max and lambda.min: [\lambda_i = \lambda_{\text{max}}e^{\frac{i-1}{n_\lambda-1}\log\left(\frac{\lambda_{\text{min}}}{\lambda_{\text{max}}}\right)} \qquad \text{for }i \in {1,\ldots,n_\lambda }.] lambda.min, lambda.max and lambda.length can be given as input by the user using the control argument of glmsmurf. The default value for lambda.length is 50. lambda.max is by default determined internally such that the intercept is the only non-zero coefficient in the model corresponding to this value of $\lambda$. lambda.min is by default equal to $10^{-4}$ times lambda.max. We make use of warm starts: the obtained coefficients for the value $\lambda_i$ are used as starting value when fitting the model using the current value $\lambda_{i+1}$. Note that we start with the largest value of $\lambda$ since this results in an intercept-only model (when lambda.max is determined internally) which is fast to fit. Additionally, the user can supply his own vector $\boldsymbol{\lambda}$ through the control argument lambda.vector. This sequence of $\lambda$-values is preferably decreasing to make efficient use of the warm starts.

In-sample selection

When selecting $\lambda$ in-sample, we fit the model for each considered value of $\boldsymbol\lambda$ to the whole sample. Then, we compute for each fitted model the selected error measure:

The optimal value for $\lambda$ is the one for which the selected error measure is minimal.

Out-of-sample selection

When selecting $\lambda$ out-of-sample, we fit the model for each considered value of $\lambda$ to the training sample. Then, we compute for each fitted model the selected error measure using the validation sample:

The optimal value for $\lambda$ is again the one for which the selected error measure is minimal. The data is split into a training sample and validation sample using the arguments validation.index or oos.prop of glmsmurf.control. The default is to use 80\% of the data as training sample and 20\% as validation sample. Note that the validation data are only used to compute the error measures for the selection of lambda.

Stratified $k$-fold cross-validation

With stratified $k$-fold cross-validation, the data set is partitioned into $k$ disjoint sets (or: folds) such that each level of the response is equally represented in each set. We fit the model with a certain value of $\boldsymbol \lambda$ to the training sample consisting of $k-1$ subsamples, and then compute an error measure using the remaining subsample (which is the validation sample). This can be repeated $k$ times such that each subsample is used exactly once as the validation sample. The average of these $k$ error measures is used as the error measure for this value of $\lambda$. The optimal value for $\lambda$ is then the one for which the average error measure is minimal. By default we use $k=5$, i.e. five-fold cross-validation. Possible error measures are

Alternatively, cross-validation can also be performed using the one standard error rule. Here, the value of $\lambda_0$ for which the average error measure is minimal is determined first as explained above. Then, we take the largest value $\lambda_{\text{opt}}$ of $\boldsymbol\lambda$ such that its average error measure $ae_{\lambda_{\text{opt}}}$ is within one standard error of the average error measure of $\lambda_0$:

[\lambda_{\text{opt}} = \max{\lambda_i | ae_{\lambda_i} \leq ae_{\lambda_0} + see_{\lambda_0}}] with $ae_{\lambda}$ and $see_{\lambda}$ the average and standard error (respectively) of the error measure when using $\lambda$ as tuning parameter. Using the same error measures as before, $\lambda$ can thus also be determined using the one standard error rule for cross-validation:

Deterministic selection of lambda

The cross-validation folds are not deterministic. The validation sample for selecting lambda out-of-sample is determined at random when no indices are provided in validation.index in the control object argument. In these cases, the selected value of lambda is hence not deterministic. When selecting lambda in-sample, or out-of-sample when indices are provided in validation.index in the control object argument, the selected value of lambda is deterministic.

Munich rent example

To select the optimal value for $\lambda$ in the example, we use stratified five-fold cross-validation with the deviance as loss measure and the one standard error rule. The number of values of $\lambda$ to consider is set to 25 using the control argument.

munich.fit.cv <- glmsmurf(formula = formu, family = gaussian(), data = rent, 
                          pen.weights = "glm.stand", lambda = "cv1se.dev",
                          control = list(lambda.length = 25L))

The optimal value of $\lambda$ can then be obtained with

munich.fit.cv$lambda

plot_lambda can be used to plot the used error measure, the deviance in our example, as a function of the logarithm of $\boldsymbol\lambda$. Note that when the argument log.lambda is set to FALSE, the actual values of $\lambda$ are used on the x-axis.

plot_lambda(munich.fit.cv)
knitr::include_graphics("Plot_lambda1.png")

The dotted vertical line corresponds to the logarithm of $\lambda_0$: the value of lambda for which the cross-validation deviance is minimal. The vertical segments indicate the standard errors of the cross-validation deviance for a certain value of lambda. The average deviance plus one standard error for $\lambda_0$ is indicated by the dotted horizontal line. As we use the one standard error rule, the optimal value for $\lambda$ is the largest such that the deviance corresponding to this value is smaller than the dotted horizontal line. The logarithm of this value is indicated by the dashed vertical line. You can also add standard plotting arguments to the plot_lambda function to adjust your plot. For example, you can use the xlim and ylim arguments to zoom in on a specific part of the plot.

# Zoomed plot
plot_lambda(munich.fit.cv, xlim = c(-7, -3.5), ylim = c(1575, 1750))
knitr::include_graphics("Plot_lambda2.png")

Graph-Guided Fused Lasso

Before, we used a Generalized Fused Lasso penalty for the predictor area in order to regularize all possible coefficient differences. Another possibility would be to use the Graph-Guided Fused Lasso penalty to only regularize the differences of coefficients of neighboring areas. When using a Graph-Guided Fused Lasso penalty, the adjacency matrix corresponding to the graph needs to be provided. The elements of this matrix are zero when two levels are not connected (areas that do not share a border in our example), and one when they are adjacent (i.e. connected). For large spatial predictors such as postal code, the adjacency matrix can be obtained using shapefiles of the region under consideration. For the Munich areas, the adjacency matrix can be inputted manually with (see e.g. @OelkerTutz for a map of the areas in Munich):

munich_adj <- matrix(0, 25, 25)
colnames(munich_adj) <- rownames(munich_adj) <- 1:25
munich_adj[1,  c(2, 3, 5, 12, 13)] <- 1
munich_adj[2,  c(1, 3, 5, 6, 8, 18)] <- 1
munich_adj[3,  c(1, 2, 4, 8, 9, 12)] <- 1
munich_adj[4,  c(3, 9, 11, 12)] <- 1
munich_adj[5,  c(1, 2, 13, 14, 16, 17, 18)] <- 1
munich_adj[6,  c(2, 7, 8, 18, 19)] <- 1
munich_adj[7,  c(6, 8, 19, 20, 25)] <- 1
munich_adj[8,  c(2, 3, 6, 7, 9, 25)] <- 1
munich_adj[9,  c(3, 4, 8, 10, 11, 21, 25)] <- 1
munich_adj[10, c(9, 11, 21, 23, 24)] <- 1
munich_adj[11, c(4, 9, 10, 12, 24)] <- 1
munich_adj[12, c(1, 3, 4, 11, 13)] <- 1
munich_adj[13, c(1, 5, 12, 14, 15)] <- 1
munich_adj[14, c(5, 13, 15, 16)] <- 1
munich_adj[15, c(13, 14, 16)] <- 1
munich_adj[16, c(5, 14, 15, 17)] <- 1
munich_adj[17, c(5, 16, 18)] <- 1
munich_adj[18, c(2, 5, 6, 17, 19)] <- 1
munich_adj[19, c(6, 7, 18, 20)] <- 1
munich_adj[20, c(7, 19, 21, 25)] <- 1
munich_adj[21, c(9, 10, 20, 22, 23, 25)] <- 1
munich_adj[22, c(21, 23)] <- 1
munich_adj[23, c(10, 21, 22, 24)] <- 1
munich_adj[24, c(10, 11, 23)] <- 1
munich_adj[25, c(7, 8, 9, 20, 21)] <- 1

Note that this matrix has to be symmetric and that the names of the areas are given as row and column names.

We can then fit the model with a Graph-Guided Fused Lasso penalty for the predictor area. The penalty parameter $\lambda$ is again selected using stratified five-fold cross-validation with the one standard error rule and the deviance as measure. The adjacency matrix is given as input using the adj.matrix argument. It should be given as a named list (using the predictor name(s)), or if only one predictor has a Graph-Guided Fused Lasso penalty, it is also possible to only give the adjacency matrix itself (not in a list).

formu2 <- rentm ~ p(area, pen = "ggflasso") + 
                  p(year, pen = "flasso") + p(size, pen = "flasso") + 
                  p(rooms, pen = "flasso") + p(quality, pen = "flasso") +
                  p(warm, pen = "lasso") + p(central, pen = "lasso") + 
                  p(tiles, pen = "lasso") + p(bathextra, pen = "lasso") +
                  p(kitchen, pen = "lasso") 

munich.fit2 <- glmsmurf(formula = formu2, family = gaussian(), data = rent, 
                        pen.weights = "glm.stand", lambda = 0.048423, 
                        adj.matrix = list(area = munich_adj))

Using a neighbor based Graph-Guided Fused Lasso penalty has the downside that only an uninterrupted cluster of levels (areas here) can be fused together. This is not always the desired behavior as e.g. suburban areas with a similar rent profile might not lie close to each other and might therefore not be fused. However, the user is free to base the Graph-Guided Fused Lasso penalty on other similarities than spatial neighbors. For example, regions with similar socio-economic status or education levels can be defined as neighbors.

References



Try the smurf package in your browser

Any scripts or data that you put into this service are public.

smurf documentation built on March 31, 2023, 7:52 p.m.