Skip to content

Specification & options

Define a model with LCLSpec, then estimate it with lcl.fit. The four focused option objects can be collected in one Options bundle shared by every fitting entry point.

import lcl
from lcl import FitOptions, InferenceOptions, OptimizationOptions, Options

results = lcl.fit(
    data,
    spec,
    options=Options(
        fit=FitOptions(seed=42, starts=3, max_em_iter=500),
        optimization=OptimizationOptions(maxiter=75, gradient_tol=1e-5),
        inference=InferenceOptions(covariance="clustered"),
    ),
)

The legacy fit_options=, optimization_options=, inference=, and diagnostics= keywords remain available, but do not mix them with options=; ambiguous partial merges raise an error.

Use separate utility_formula and membership_formula fields for formula-based designs. LCLSpec is immutable, so it can be reused safely across fitting and cross-validation.

Fitting

lcl.fit(data, spec, *, options=None, fit_options=None, optimization_options=None, inference=None, diagnostics=None, variable_labels=None, dems_data=None, progress_callback=None)

Fit a latent-class conditional-logit model from an :class:LCLSpec.

Parameters:

Name Type Description Default
data object

Long-format choice data.

required
spec LCLSpec

Declarative model specification.

required
fit_options FitOptions | None

EM algorithm options.

None
optimization_options OptimizationOptions | None

M-step optimizer options.

None
inference InferenceOptions | None

Covariance and standard-error options.

None
diagnostics DiagnosticsOptions | None

Diagnostic thresholds and switches.

None
variable_labels Mapping[str, str] | None

Optional display labels for raw model/DataFrame variable names. Labels supplement any labels stored on spec and are used only in presentation tables.

None

Returns:

Type Description
LCLResults

Fitted latent-class results.

Notes

Latent-class estimation does not accept case or survey weights. Use :class:~lcl.conditional_logit.ConditionalLogit when weighting is required.

Source code in src/lcl/__init__.py
def fit(
    data: object,
    spec: LCLSpec,
    *,
    options: Options | None = None,
    fit_options: FitOptions | None = None,
    optimization_options: OptimizationOptions | None = None,
    inference: InferenceOptions | None = None,
    diagnostics: DiagnosticsOptions | None = None,
    variable_labels: _Mapping[str, str] | None = None,
    dems_data: object | None = None,
    progress_callback: _Callable[[dict[str, object]], None] | None = None,
) -> LCLResults:
    """Fit a latent-class conditional-logit model from an :class:`LCLSpec`.

    Parameters
    ----------
    data : object
        Long-format choice data.
    spec : LCLSpec
        Declarative model specification.
    fit_options : FitOptions | None, optional
        EM algorithm options.
    optimization_options : OptimizationOptions | None, optional
        M-step optimizer options.
    inference : InferenceOptions | None, optional
        Covariance and standard-error options.
    diagnostics : DiagnosticsOptions | None, optional
        Diagnostic thresholds and switches.
    variable_labels : Mapping[str, str] | None, optional
        Optional display labels for raw model/DataFrame variable names.  Labels
        supplement any labels stored on ``spec`` and are used only in
        presentation tables.

    Returns
    -------
    LCLResults
        Fitted latent-class results.

    Notes
    -----
    Latent-class estimation does not accept case or survey weights.  Use
    :class:`~lcl.conditional_logit.ConditionalLogit` when weighting is required.
    """
    model = LatentClassConditionalLogit(spec=spec)
    return model.fit(
        data=data,
        options=options,
        fit_options=fit_options,
        optimization_options=optimization_options,
        inference=inference,
        diagnostics=diagnostics,
        variable_labels=variable_labels,
        dems_data=dems_data,
        progress_callback=progress_callback,
    )

Model specification

lcl.LCLSpec(ids, utility=None, membership=None, classes=2, constraints=None, utility_formula=None, membership_formula=None, variable_labels=None) dataclass

Declarative latent-class conditional-logit specification.

Parameters:

Name Type Description Default
ids ChoiceIds

Identifier and choice columns for the long-format dataset.

required
utility Sequence[str] | None

Alternative-specific variables in the utility specification. Omit when utility_formula provides the utility design.

None
membership Sequence[str] | None

Panel-level variables for class-membership probabilities. Omit when membership_formula provides the demographic design.

None
classes int

Number of latent classes.

2
constraints mapping or sequence

Coefficient constraints. The current estimation engine supports one negative coefficient, typically a price, cost, or travel-time numeraire.

None
utility_formula str | None

Formulaic string for the alternative-specific utility specification, such as "choice ~ cost + C(mode)". A right-hand-side-only utility formula is permitted when the choice column is supplied by :class:ChoiceIds.

None
membership_formula str | None

Right-hand-side Formulaic string for the class-membership demographic regression, such as "~ income + C(segment)".

None
variable_labels Mapping[str, str] | None

Optional mapping from raw DataFrame/model variable names to human-readable labels used in printed coefficient and WTP/tradeoff tables. Exact Formulaic-expanded names may also be labeled directly; otherwise labels for raw categorical columns are reused for terms such as "C(segment)[T.high]".

None

negative_constraint property

Return the single negative constraint, if present.

negative_constraints property

Return normalized negative-coefficient constraints.

numeraire property

Return the constrained variable used as the numeraire.

numeraire_min_abs property

Return the numeraire floor implied by the specification.

__post_init__()

Validate internal consistency.

Source code in src/lcl/spec.py
def __post_init__(self) -> None:
    """Validate internal consistency."""
    if self.classes < 2:
        raise ValueError("LCLSpec.classes must be at least 2.")
    if self.utility_formula is None and not self.utility:
        raise ValueError("LCLSpec requires either utility variables or a formula.")
    if len(self.negative_constraints) > 1:
        raise NotImplementedError(
            "The current latent-class estimator supports one negative "
            "coefficient constraint. Multiple constraints can be added once "
            "the optimizer is generalized beyond a single numeraire row."
        )

constraint_rows()

Return serializable constraint metadata.

Source code in src/lcl/spec.py
def constraint_rows(self) -> list[dict[str, object]]:
    """Return serializable constraint metadata."""
    return constraint_summary_rows(self.negative_constraints)

summary_lines()

Return a compact, human-readable specification summary.

Source code in src/lcl/spec.py
def summary_lines(self) -> list[str]:
    """Return a compact, human-readable specification summary."""
    lines = [
        "Latent-class conditional logit",
        f"Classes: {self.classes}",
        f"Panel id: {self.ids.panel}",
        f"Case id: {self.ids.case}",
        f"Alternative id: {self.ids.alt}",
        f"Choice column: {self.ids.choice}",
        "",
        "Utility variables:",
    ]
    if self.utility_formula is not None:
        lines.append(f"  formula: {self.utility_formula}")
    else:
        for variable in self.utility or []:
            suffix = ""
            for constraint in self.negative_constraints:
                if constraint.variable == variable:
                    suffix = f" [negative, min_abs={constraint.min_abs:g}]"
            label = self._display_variable(variable)
            lines.append(f"  {label}{suffix}")
    lines.append("")
    lines.append("Class-membership variables:")
    if self.membership:
        lines.extend(
            f"  {self._display_variable(variable)}" for variable in self.membership
        )
    elif self.membership_formula is not None:
        lines.append(f"  formula: {self.membership_formula}")
    else:
        lines.append("  none")
    return lines

lcl.ChoiceIds(alt, case, panel, choice) dataclass

Column names identifying a long-format choice dataset.

Parameters:

Name Type Description Default
alt str

Alternative identifier column.

required
case str

Choice-situation identifier column.

required
panel str

Decision-maker or panel identifier column.

required
choice str

Boolean or binary chosen-alternative indicator column.

required

lcl.NegativeCoefficient(variable=None, min_abs=DEFAULT_NEGATIVE_MIN_ABS, units=None, warn_below=None) dataclass

Constrain a coefficient to be strictly negative.

Parameters:

Name Type Description Default
variable str | None

Name of the variable being constrained. It may be omitted when the object is supplied in a mapping keyed by variable name.

None
min_abs float

Minimum absolute magnitude of the structural coefficient. The forward transform is -(softplus(raw) + min_abs).

1e-5
units str | None

Optional human-readable units for summaries and audit reports.

None
warn_below float | None

Optional threshold used by diagnostics to flag weakly identified numeraires.

None

__post_init__()

Validate constraint settings.

Source code in src/lcl/constraints.py
def __post_init__(self) -> None:
    """Validate constraint settings."""
    if self.min_abs <= 0:
        raise ValueError("NegativeCoefficient.min_abs must be positive.")
    if self.warn_below is not None and self.warn_below <= 0:
        raise ValueError("NegativeCoefficient.warn_below must be positive.")

bind(variable)

Return a copy tied to variable.

Parameters:

Name Type Description Default
variable str

Variable name from a specification mapping.

required

Returns:

Type Description
NegativeCoefficient

A constraint with a concrete variable name.

Source code in src/lcl/constraints.py
def bind(self, variable: str) -> "NegativeCoefficient":
    """Return a copy tied to ``variable``.

    Parameters
    ----------
    variable : str
        Variable name from a specification mapping.

    Returns
    -------
    NegativeCoefficient
        A constraint with a concrete variable name.
    """
    if self.variable is not None and self.variable != variable:
        raise ValueError(
            "NegativeCoefficient variable mismatch: "
            f"{self.variable!r} != {variable!r}."
        )
    return replace(self, variable=variable)

forward(raw)

Map unconstrained parameters to negative structural coefficients.

Source code in src/lcl/constraints.py
def forward(self, raw: Float64[Array, "..."]) -> Float64[Array, "..."]:
    """Map unconstrained parameters to negative structural coefficients."""
    return -(softplus(raw) + self.min_abs)

hessian_diag(raw)

Return the diagonal second derivative of :meth:forward.

Source code in src/lcl/constraints.py
def hessian_diag(self, raw: Float64[Array, "..."]) -> Float64[Array, "..."]:
    """Return the diagonal second derivative of :meth:`forward`."""
    d1 = self.jacobian_diag(raw)
    return d1 * (1.0 + d1)

jacobian_diag(raw)

Return the diagonal Jacobian element of :meth:forward.

Source code in src/lcl/constraints.py
def jacobian_diag(self, raw: Float64[Array, "..."]) -> Float64[Array, "..."]:
    """Return the diagonal Jacobian element of :meth:`forward`."""
    return -sigmoid(raw)

Options

lcl.options.FitOptions(seed=0, max_em_iter=2000, em_tol=1e-08, score_tol=0.0001, polish=True, polish_maxiter=25, num_devices=device_count(), check_interval=1, starts=1, start_method='panel_partition') dataclass

Latent-class EM and multi-start settings.

Parameters:

Name Type Description Default
seed int

Base seed for the panel partition used to build starting values.

0
max_em_iter int

Maximum number of EM recursions.

2000
em_tol float

Stopping tolerance on the Aitken-extrapolated log-likelihood change per panel. Because EM converges linearly, the raw iteration-to-iteration change understates the distance to the optimum by 1 / (1 - r) where r is the observed rate; the criterion therefore compares the extrapolated limit rather than the raw change. Normalizing by the panel count keeps the tolerance's meaning fixed as the sample grows.

1e-8
score_tol float

Stopping tolerance on the maximum absolute component of the observed-data score, per panel. EM terminates when either criterion is met, and the public converged flag additionally requires this one.

1e-4
polish bool

Run safeguarded Newton steps on the observed-data log likelihood after EM, using the exact analytic score and Hessian. EM alone converges linearly and reliably stops short of a stationary point; the polish makes the reported optimum stationary, which is what the observed information and the sandwich covariance assume. A polish step is kept only if it does not decrease the log likelihood.

True
polish_maxiter int

Maximum number of observed-data Newton iterations.

25
num_devices int

Number of JAX devices across which class-specific M-steps are sharded.

device_count()
check_interval int

Number of EM recursions between convergence checks.

1
starts int

Number of independent EM starts. The start with the highest final log likelihood is kept.

1
start_method str

Strategy used to build starting values.

"panel_partition"
Notes

The class is frozen so a configuration can be hashed and used as a static argument to a cached JIT-compiled M-step.

__post_init__()

Validate EM and multi-start settings.

Source code in src/lcl/options.py
def __post_init__(self) -> None:
    """Validate EM and multi-start settings."""
    if self.em_tol <= 0:
        raise ValueError("em_tol must be positive.")
    if self.score_tol <= 0:
        raise ValueError("score_tol must be positive.")
    if self.max_em_iter < 1:
        raise ValueError("max_em_iter must be at least one.")
    if self.polish_maxiter < 0:
        raise ValueError("polish_maxiter must be nonnegative.")
    if self.check_interval <= 0:
        raise ValueError("check_interval must be positive.")
    if self.starts < 1:
        raise ValueError("starts must be at least 1.")
    if self.start_method != "panel_partition":
        raise ValueError(
            "Only start_method='panel_partition' is currently supported."
        )
    available_devices = device_count()
    if not 1 <= self.num_devices <= available_devices:
        raise ValueError(
            "num_devices must be between 1 and the number of available JAX "
            f"devices ({available_devices})."
        )

lcl.options.OptimizationOptions(maxiter=75, newton_decrement_tol=DEFAULT_NEWTON_DECREMENT_TOL, hessian_damping=0.0, max_step_norm=1000.0, initial_trust_radius=1.0, line_search_maxiter=40, accept_any_decrease=False, gradient_tol=DEFAULT_NEWTON_DECREMENT_TOL) dataclass

Safeguarded exact-Newton settings.

Parameters:

Name Type Description Default
maxiter int

Maximum number of Newton iterations.

75
newton_decrement_tol float

Stopping tolerance on the Newton decrement :math:\lambda = \sqrt{g' H^{-1} g}, not on a raw gradient norm. The decrement is invariant to nonsingular diagonal rescaling of the parameters and approximates :math:\sqrt{2(f - f^\star)}, so a value of 1e-5 on the package's per-observation objective corresponds to roughly 5e-11 in objective units.

1e-5
hessian_damping float

Initial diagonal shift used only when the undamped Cholesky solve does not produce a finite descent direction.

0.0
max_step_norm float

Upper bound on the adaptive trust radius, in the local curvature metric.

1000.0
initial_trust_radius float

Starting trust radius, in the local curvature metric. It expands or contracts with agreement between the quadratic model and the objective.

1.0
line_search_maxiter int

Maximum number of Armijo backtracking steps per Newton iteration.

40
accept_any_decrease bool

Accept a finite step that merely decreases the objective when the stricter Armijo sufficient-decrease rule is not met.

False
gradient_tol float

Deprecated alias for newton_decrement_tol. Reading it returns the resolved tolerance; passing it emits a :class:DeprecationWarning.

DEFAULT_NEWTON_DECREMENT_TOL
Notes

The class is frozen so a configuration can be hashed and used as a static argument to a cached JIT-compiled M-step. Use :func:dataclasses.replace to derive a modified configuration.

__post_init__()

Resolve the deprecated alias and validate supported settings.

Source code in src/lcl/options.py
def __post_init__(self) -> None:
    """Resolve the deprecated alias and validate supported settings."""
    # The two fields share a default, so "explicitly set" means "not the
    # default".  An explicit newton_decrement_tol always wins, which keeps
    # dataclasses.replace well behaved on either spelling.
    new_set = self.newton_decrement_tol != DEFAULT_NEWTON_DECREMENT_TOL
    old_set = self.gradient_tol != DEFAULT_NEWTON_DECREMENT_TOL
    if old_set:
        warnings.warn(
            "OptimizationOptions.gradient_tol is deprecated; use "
            "newton_decrement_tol. The value is a Newton-decrement "
            "tolerance, not a gradient norm.",
            DeprecationWarning,
            stacklevel=3,
        )
    tolerance = self.newton_decrement_tol if new_set else self.gradient_tol
    object.__setattr__(self, "newton_decrement_tol", tolerance)
    object.__setattr__(self, "gradient_tol", tolerance)

    if self.maxiter < 0:
        raise ValueError("maxiter must be nonnegative.")
    if self.newton_decrement_tol <= 0:
        raise ValueError("newton_decrement_tol must be positive.")
    if self.hessian_damping < 0:
        raise ValueError("hessian_damping must be nonnegative.")
    if self.max_step_norm <= 0:
        raise ValueError("max_step_norm must be positive.")
    if self.initial_trust_radius <= 0:
        raise ValueError("initial_trust_radius must be positive.")
    if self.line_search_maxiter < 0:
        raise ValueError("line_search_maxiter must be nonnegative.")

lcl.options.InferenceOptions(covariance='clustered', cluster='panel', finite_sample_correction=True, skip=False) dataclass

Covariance and standard-error settings.

Parameters:

Name Type Description Default
covariance str

One of "clustered", "robust", or "unadjusted". Latent-class models accept only "clustered" and "unadjusted", because the latent class is shared within a panel.

"clustered"
cluster str | None

Grouping used when covariance="clustered". "panel" clusters at the decision-maker. Any other string names a column of the estimation data holding a coarser grouping, which must be constant within each panel — a household, market, or region identifier, for instance. The value is ignored when covariance is not "clustered".

"panel"
finite_sample_correction bool

Apply the G / (G - 1) cluster multiplier, or n / (n - 1) for the unclustered sandwich, matching Stata's maximum-likelihood convention.

True
skip bool

Skip covariance estimation entirely and return a matrix of NaN.

False

cluster_column property

Return the data column naming a coarser cluster, if any.

clusters_at_panel property

Report whether clustering uses the panel identifier itself.

__post_init__()

Normalize and validate covariance settings.

Source code in src/lcl/options.py
def __post_init__(self) -> None:
    """Normalize and validate covariance settings."""
    covariance = self.covariance.lower()
    if covariance in {"none", "unadjusted", "hessian"}:
        covariance = "unadjusted"
    elif covariance == "clustered":
        covariance = "clustered"
    elif covariance in {"robust", "sandwich", "huber-white"}:
        covariance = "robust"
    else:
        raise ValueError(
            "InferenceOptions.covariance must be one of 'clustered', "
            "'robust', 'sandwich', 'huber-white', 'unadjusted', or 'none'."
        )
    self.covariance = covariance
    if covariance == "clustered" and self.cluster is None:
        raise ValueError(
            "covariance='clustered' requires a cluster grouping. Pass "
            "cluster='panel' for decision-maker clustering, cluster='<column>' "
            "for a coarser grouping, or covariance='robust'/'unadjusted'."
        )
    if self.cluster is not None and not isinstance(self.cluster, str):
        raise ValueError("InferenceOptions.cluster must be a string or None.")

lcl.options.DiagnosticsOptions(check_separation=True, check_collinearity=True, warn_near_zero_numeraire=True, warn_large_coefficients=True, separation_threshold=1e-08, near_zero_numeraire_threshold=0.001, large_coefficient_threshold=25.0) dataclass

Diagnostic switches and warning thresholds.

Parameters:

Name Type Description Default
check_separation bool

Report whether any demographic cell has a class-membership probability pinned at zero. When one does, the membership coefficients for that cell are unbounded and the observed information is singular in that direction, which is the usual cause of an otherwise puzzling rank deficiency.

True
check_collinearity bool

Report the rank and conditioning of the observed information.

True
warn_near_zero_numeraire bool

Warn when a class's numeraire coefficient sits near its floor, which makes every willingness-to-pay ratio for that class unstable.

True
warn_large_coefficients bool

Warn on implausibly large utility coefficients.

True
separation_threshold float

Membership probability at or below which a cell counts as separated.

1e-8
near_zero_numeraire_threshold float

Numeraire magnitude below which the near-zero warning fires.

1e-3
large_coefficient_threshold float

Absolute coefficient magnitude above which the large-coefficient warning fires.

25.0

lcl.options.Options(fit=FitOptions(), optimization=OptimizationOptions(), inference=InferenceOptions(), diagnostics=DiagnosticsOptions()) dataclass

Complete configuration shared by all model-fitting entry points.