Skip to content

Conditional logit

The McFadden conditional logit estimates one taste vector for the full sample. It provides a useful homogeneous benchmark for latent-class specifications.

Use utility_formula for new formula-based specifications, and use the current optimization and inference option objects:

from lcl import ConditionalLogit, InferenceOptions, OptimizationOptions, Options

results = ConditionalLogit(numeraire="price").fit(
    data,
    alts_col="alternative",
    cases_col="choice_situation",
    panels_col="respondent",
    utility_formula="chosen ~ price + time + C(mode)",
    weights="survey_weight",
    options=Options(
        optimization=OptimizationOptions(gradient_tol=1e-6),
        inference=InferenceOptions(covariance="clustered"),
    ),
)

coefficient_table = results.summarize_betas(show=False)

Weights are case-level. Prefer a column name or case-keyed mapping because those forms preserve identity when rows are reordered. If case IDs repeat across panels, key a mapping by (panel_id, case_id). A sequence is interpreted in first-case-appearance order and realigned after encoding.

With panels_col, BIC, CAIC, and adjusted BIC use the number of panels as their sample size; otherwise they use the number of choice situations. A softplus-constrained numeraire does not have an ordinary zero-null p-value, so its reported p-value is NaN.

covariance="clustered" clusters at the panel level when panels_col is provided. covariance="robust" always requests case-level Huber–White inference; the two labels are not aliases. The result also reports the null log likelihood, McFadden rho-squared, final score, and information diagnostics.

Prediction returns a CLPrediction rather than a bare frame. Probabilities remain in prediction.predicted_probs, with WTP, elasticities, market shares, aggregate elasticities, denominator diagnostics, and surplus available through the same methods as latent-class prediction.

prediction = results.predict(counterfactual_data, panel_weights="survey_weight")
wtp = prediction.wtp("time", se="bootstrap", bootstrap_draws=1_000)
elasticities = prediction.elasticities(["price", "time"])
market_shares = prediction.market_shares()

Model

lcl.ConditionalLogit(numeraire=None, numeraire_min_abs=DEFAULT_NEGATIVE_MIN_ABS)

Bases: ChoiceModel

Specification and estimation for standard Multinomial Conditional Logit models.

Unlike the Latent Class variant, this model estimates a single vector of homogeneous taste parameters across the entire sample.

Parameters:

Name Type Description Default
numeraire str | None

The name of the variable (e.g., 'price') to use as the numeraire. If provided, its coefficient is bounded to be strictly negative to ensure logically consistent utility scaling and willingness-to-pay calculations.

None

Attributes:

Name Type Description
numeraire_idx int | None

The column index of the numeraire variable in the expanded design matrix.

Create an unfitted conditional-logit model specification.

Source code in src/lcl/conditional_logit.py
def __init__(
    self,
    numeraire: str | None = None,
    numeraire_min_abs: float = DEFAULT_NEGATIVE_MIN_ABS,
) -> None:
    """Create an unfitted conditional-logit model specification."""
    super().__init__()
    self.numeraire = numeraire
    self.numeraire_min_abs = numeraire_min_abs
    self.numeraire_idx: int | None = None

fit(data, alts_col, cases_col, panels_col=None, utility_formula=None, choice_col=None, case_varnames=None, variable_labels=None, weights=None, weight_type='probability', init_beta=None, options=None, optimization_options=None, inference=None)

Fit the conditional logit model via Maximum Likelihood Estimation.

Supports both R-style formulas (via formulaic) and explicit lists of variables.

Parameters:

Name Type Description Default
data DataFrame | DataFrame | ArrayLike

The main dataset containing choice situations and alternatives in long format.

required
alts_col str

Name of the column containing alternative identifiers.

required
cases_col str

Name of the column grouping observations into distinct choice situations.

required
panels_col str | None

Name of the column mapping observations to specific decision-makers. If provided, the covariance matrix is automatically clustered at the panel level. If omitted, standard Huber-White robust standard errors are computed.

None
utility_formula str | None

Preferred Formulaic string for the alternative-specific utility specification. If it includes a left-hand side, that outcome is used as the choice indicator; otherwise choice_col must be provided.

None
choice_col str | None

Name of the boolean/binary column indicating chosen alternatives.

None
case_varnames Sequence[str] | None

List of alternative-specific variables.

None
variable_labels Mapping[str, str] | None

Optional mapping from raw DataFrame/model variable names to human-readable labels used in printed coefficient tables.

None
weights str, Mapping, ArrayLike, or None

Case-level weights. A string names a data column that must be constant within case; a mapping is keyed by case ID (or (panel_id, case_id) when case IDs repeat); a vector follows first case appearance in the input data and is realigned after encoding.

None
weight_type (probability, frequency)

How weights enter the variance, mirroring Stata's pweight and fweight. "probability" treats them as survey or sampling weights, so the score of the weighted objective for case i is w_i s_i and the robust meat is sum_i w_i^2 s_i s_i'. "frequency" treats them as replication counts for collapsed data, giving sum_i w_i s_i s_i' and a sample size of sum_i w_i. Point estimates and the log likelihood are identical either way; only robust and clustered standard errors differ, and they coincide when every weight is one. Survey weights are the common case in household panel data, so they are the default.

"probability"
init_beta ArrayLike | None

(K,) vector of initial taste parameters.

None
optimization_options OptimizationOptions | None

Preferred safeguarded exact-Newton settings.

None
inference InferenceOptions | None

Preferred covariance and standard-error settings.

None

Returns:

Type Description
class:`~lcl.conditional_logit.CLResults`

Results container housing coefficients, robust standard errors, and fit statistics.

Source code in src/lcl/conditional_logit.py
def fit(
    self,
    data: Any,
    alts_col: str,
    cases_col: str,
    panels_col: str | None = None,
    utility_formula: str | None = None,
    choice_col: str | None = None,
    case_varnames: Sequence[str] | None = None,
    variable_labels: Mapping[str, str] | None = None,
    weights: (
        str
        | Mapping[object, float | int]
        | Sequence[float | int]
        | ArrayLike
        | None
    ) = None,
    weight_type: str = "probability",
    init_beta: ArrayLike | None = None,
    options: Options | None = None,
    optimization_options: OptimizationOptions | None = None,
    inference: InferenceOptions | None = None,
) -> "CLResults":
    """Fit the conditional logit model via Maximum Likelihood Estimation.

    Supports both R-style formulas (via `formulaic`) and explicit lists of variables.

    Parameters
    ----------
    data : pd.DataFrame | pl.DataFrame | ArrayLike
        The main dataset containing choice situations and alternatives in long format.
    alts_col : str
        Name of the column containing alternative identifiers.
    cases_col : str
        Name of the column grouping observations into distinct choice situations.
    panels_col : str | None, optional
        Name of the column mapping observations to specific decision-makers. If provided,
        the covariance matrix is automatically clustered at the panel level. If omitted,
        standard Huber-White robust standard errors are computed.
    utility_formula : str | None, optional
        Preferred Formulaic string for the alternative-specific utility
        specification.  If it includes a left-hand side, that outcome is used
        as the choice indicator; otherwise ``choice_col`` must be provided.
    choice_col : str | None, optional
        Name of the boolean/binary column indicating chosen alternatives.
    case_varnames : Sequence[str] | None, optional
        List of alternative-specific variables.
    variable_labels : Mapping[str, str] | None, optional
        Optional mapping from raw DataFrame/model variable names to
        human-readable labels used in printed coefficient tables.
    weights : str, Mapping, ArrayLike, or None, optional
        Case-level weights. A string names a data column that must be constant
        within case; a mapping is keyed by case ID (or ``(panel_id, case_id)``
        when case IDs repeat); a vector follows first case appearance in the
        input data and is realigned after encoding.
    weight_type : {"probability", "frequency"}, default="probability"
        How ``weights`` enter the variance, mirroring Stata's ``pweight`` and
        ``fweight``.  ``"probability"`` treats them as survey or sampling
        weights, so the score of the weighted objective for case ``i`` is
        ``w_i s_i`` and the robust meat is ``sum_i w_i^2 s_i s_i'``.
        ``"frequency"`` treats them as replication counts for collapsed data,
        giving ``sum_i w_i s_i s_i'`` and a sample size of ``sum_i w_i``.
        Point estimates and the log likelihood are identical either way; only
        robust and clustered standard errors differ, and they coincide when
        every weight is one.  Survey weights are the common case in household
        panel data, so they are the default.
    init_beta : ArrayLike | None, optional
        ``(K,)`` vector of initial taste parameters.
    optimization_options : OptimizationOptions | None, optional
        Preferred safeguarded exact-Newton settings.
    inference : InferenceOptions | None, optional
        Preferred covariance and standard-error settings.

    Returns
    -------
    :class:`~lcl.conditional_logit.CLResults`
        Results container housing coefficients, robust standard errors, and fit statistics.
    """
    resolved_options = _resolve_options(
        options,
        optimization_options=optimization_options,
        inference=inference,
    )
    optimization_options = resolved_options.optimization
    inference = resolved_options.inference

    # If no panels are provided, we substitute cases for panels purely to satisfy
    # the contiguity checks in the ingestion engine.
    _internal_panels_col = panels_col if panels_col is not None else cases_col

    parsed_data = self._ingest_data(
        data=data,
        alts_col=alts_col,
        cases_col=cases_col,
        panels_col=_internal_panels_col,
        utility_formula=utility_formula,
        membership_formula=None,
        choice_col=choice_col,
        case_varnames=case_varnames,
        dem_varnames=None,
        dems_data=None,
    )

    self._pre_fit(
        parsed_data.case_varnames,
        None,
        self.numeraire,
        variable_labels=variable_labels,
    )
    self.num_vars = len(self.case_varnames)

    if self.numeraire:
        try:
            self.numeraire_idx = self.case_varnames.index(self.numeraire)
        except ValueError:
            raise ValueError(
                f"Numeraire '{self.numeraire}' not found in expanded design matrix."
            )
    else:
        self.numeraire_idx = None

    # Format data for MLE
    resolved_weight_type = _resolve_weight_type(weight_type)
    aligned_weights = self._resolve_case_weights(
        data,
        parsed_data,
        weights,
        cases_col=cases_col,
        panels_col=_internal_panels_col,
    )
    data_struct, weights_arr, init_beta_arr = self._setup_data(
        parsed=parsed_data,
        weights=aligned_weights,
        init_beta=init_beta,
    )

    diff_unchosen_chosen = _diff_unchosen_chosen(data_struct)

    # Resolve a coarser clustering to encoded panel order, then broadcast it
    # to cases: cases nest inside panels, so a grouping constant within panel
    # is well defined at either level.
    cluster_of_cases = None
    num_clusters = None
    cluster_column = inference.cluster_column
    if cluster_column is not None:
        cluster_by_panel, num_clusters = self._resolve_panel_cluster_ids(
            data,
            parsed_data,
            cluster_column,
            panels_col=_internal_panels_col,
        )
        if data_struct.panels_of_cases is None:
            raise ValueError("Panel identifiers are required for clustering.")
        cluster_of_cases = jnp.asarray(cluster_by_panel)[
            data_struct.panels_of_cases
        ]
        logger.info(
            "Clustering standard errors on %r: %s groups.",
            cluster_column,
            num_clusters,
        )

    # Estimate the conditional logit model
    optim_res = _minimize(
        _loglik_value,
        _loglik_gradient,
        init_beta_arr,
        args=(diff_unchosen_chosen, weights_arr),
        optimization_options=optimization_options,
        numeraire_idx=self.numeraire_idx,
        numeraire_min_abs=self.numeraire_min_abs,
        objective_scale=jnp.sum(weights_arr),
    )

    # Build Results
    estim_time_sec = time() - self._fit_start_time
    logger.info("Estimation time: %.3f seconds", estim_time_sec)

    return CLResults(
        model_spec=self,
        optim_res=optim_res,
        data_struct=data_struct,
        inference=inference,
        estim_time_sec=estim_time_sec,
        has_panels=panels_col is not None,
        case_weights=weights_arr,
        weight_type=resolved_weight_type,
        cluster_of_cases=cluster_of_cases,
        num_clusters=num_clusters,
    )

Results

lcl.results.CLResults(model_spec, optim_res, data_struct, inference, estim_time_sec, has_panels, case_weights, weight_type='probability', cluster_of_cases=None, num_clusters=None)

Post-estimation results and inference container for Conditional Logit.

Automatically handles the derivation of robust standard errors via the Delta Method if a softplus-constrained numeraire is specified in the model specification.

Compute inference summaries from a fitted conditional-logit model.

Parameters:

Name Type Description Default
model_spec :class:`~lcl.conditional_logit.ConditionalLogit`

Fitted model specification and variable metadata.

required
optim_res :class:`~lcl._struct.OptimizeResult`

Optimizer output containing parameters, gradients, and Hessian inverse.

required
data_struct :class:`~lcl._struct.Data`

Encoded estimation data.

required
inference :class:`~lcl.options.InferenceOptions`

Covariance and standard-error configuration.

required
estim_time_sec float

Wall-clock estimation time in seconds.

required
has_panels bool

Whether robust covariance should cluster scores at the panel level.

required
case_weights ArrayLike

Case weights aligned with encoded choice situations.

required
weight_type (probability, frequency)

Interpretation of case_weights for the robust variance.

"probability"
cluster_of_cases ArrayLike | None

Zero-indexed cluster identifier per case, for clustering coarser than the panel.

None
num_clusters int | None

Number of distinct clusters implied by cluster_of_cases.

None
Source code in src/lcl/conditional_logit.py
def __init__(
    self,
    model_spec: ConditionalLogit,
    optim_res: OptimizeResult,
    data_struct: Data,
    inference: InferenceOptions,
    estim_time_sec: float,
    has_panels: bool,
    case_weights: ArrayLike,
    weight_type: str = "probability",
    cluster_of_cases: ArrayLike | None = None,
    num_clusters: int | None = None,
) -> None:
    """Compute inference summaries from a fitted conditional-logit model.

    Parameters
    ----------
    model_spec : :class:`~lcl.conditional_logit.ConditionalLogit`
        Fitted model specification and variable metadata.
    optim_res : :class:`~lcl._struct.OptimizeResult`
        Optimizer output containing parameters, gradients, and Hessian inverse.
    data_struct : :class:`~lcl._struct.Data`
        Encoded estimation data.
    inference : :class:`~lcl.options.InferenceOptions`
        Covariance and standard-error configuration.
    estim_time_sec : float
        Wall-clock estimation time in seconds.
    has_panels : bool
        Whether robust covariance should cluster scores at the panel level.
    case_weights : ArrayLike
        Case weights aligned with encoded choice situations.
    weight_type : {"probability", "frequency"}, default="probability"
        Interpretation of ``case_weights`` for the robust variance.
    cluster_of_cases : ArrayLike | None, optional
        Zero-indexed cluster identifier per case, for clustering coarser than
        the panel.
    num_clusters : int | None, optional
        Number of distinct clusters implied by ``cluster_of_cases``.
    """
    self.model = model_spec
    self.data = data_struct
    self.inference = inference
    self.has_panels = has_panels
    self.case_weights = jnp.asarray(case_weights)
    self.weight_type = _resolve_weight_type(weight_type)
    self.converged = optim_res.success
    self.latent_coeff_ = optim_res.params

    # Recover structural parameters if numeraire was applied
    self.coeff_ = _to_structural_betas(
        self.latent_coeff_,
        self.model.numeraire_idx,
        self.model.numeraire_min_abs,
    )
    self.hess_inv = optim_res.hess_inv
    self.information_diagnostics = optim_res.information_diagnostics

    # Both robust branches use uncentered scores and the same finite-sample
    # multiplier; they differ only in the level at which scores are summed.
    # Clustering aggregates weighted scores first, so the two weight
    # interpretations coincide once a cluster sum has been taken.
    if inference.skip:
        self.hess_inv = jnp.full_like(self.hess_inv, jnp.nan)
        latent_cov = self.hess_inv
    elif inference.covariance in {"clustered", "robust"}:
        cluster_ids, num_groups = self._resolve_cluster_groups(
            inference,
            data_struct,
            has_panels,
            cluster_of_cases,
            num_clusters,
        )
        if cluster_ids is not None:
            if num_groups is None or num_groups < 2:
                raise ValueError(
                    "Cluster-robust covariance requires at least two clusters."
                )
            grad_g = _aggregate_scores(
                optim_res.grad_n * jnp.asarray(case_weights)[:, None],
                cluster_ids,
                num_groups,
            )
            latent_cov = _robust_covariance(
                self.hess_inv, grad_g, inference.finite_sample_correction
            )
        else:
            # Standard Huber-White Robust Standard Errors
            latent_cov = _robust_covariance(
                self.hess_inv,
                optim_res.grad_n,
                inference.finite_sample_correction,
                weights=case_weights,
                weight_type=self.weight_type,
            )
    else:
        latent_cov = self.hess_inv

    # The public covariance is reported on the same scale as ``coeff_``, so
    # ``sqrt(diag(cov_matrix))`` reproduces ``stderr``.  ``latent_cov_matrix``
    # keeps the unconstrained parameterization the delta method and the
    # parametric bootstrap consume.
    self.latent_cov_matrix = latent_cov
    if self.model.numeraire_idx is not None:

        def struct_fn(
            p: Float64[Array, "alt_vars"],
        ) -> Float64[Array, "alt_vars"]:
            """Map latent coefficients to structural coefficients."""
            return _to_structural_betas(
                p, self.model.numeraire_idx, self.model.numeraire_min_abs
            )

        jac = jacrev(struct_fn)(self.latent_coeff_)
        struct_cov = jac @ latent_cov @ jac.T
        self.cov_matrix = 0.5 * (struct_cov + struct_cov.T)
    else:
        self.cov_matrix = latent_cov
    self.stderr = jnp.sqrt(jnp.diag(self.cov_matrix))

    self.zvalues = onp.array(self.coeff_ / self.stderr, dtype=onp.float64)
    self.pvalues = 2 * norm.cdf(-onp.abs(self.zvalues))
    if self.model.numeraire_idx is not None:
        # The softplus transform makes the constrained coefficient strictly
        # negative by construction, so a test against zero is vacuous: the
        # null is excluded by the parameterization, not by the data.
        self.zvalues[self.model.numeraire_idx] = onp.nan
        self.pvalues[self.model.numeraire_idx] = onp.nan
    self.loglikelihood = -optim_res.neg_loglik
    self.estimation_message = optim_res.message
    self.total_iter = optim_res.nit
    self.estim_time_sec = estim_time_sec
    self.sample_size = data_struct.num_cases
    self.information_criterion_sample_size = (
        data_struct.num_panels
        if has_panels and data_struct.num_panels is not None
        else data_struct.num_cases
    )
    self.total_fun_eval = optim_res.nfev
    self.grad_n = optim_res.grad_n
    self.observed_score_max = float(
        jnp.max(
            jnp.abs(jnp.sum(optim_res.grad_n * self.case_weights[:, None], axis=0))
        )
    )

    # Information criteria
    self.aic = 2 * len(self.coeff_) - 2 * self.loglikelihood
    self.caic = (
        len(self.coeff_) * (jnp.log(self.information_criterion_sample_size) + 1)
        - 2 * self.loglikelihood
    )
    self.bic = (
        jnp.log(self.information_criterion_sample_size) * len(self.coeff_)
        - 2 * self.loglikelihood
    )
    self.adjusted_bic = (
        jnp.log((self.information_criterion_sample_size + 2) / 24)
        * len(self.coeff_)
        - 2 * self.loglikelihood
    )
    alternatives_per_case = jnp.bincount(
        data_struct.cases, length=data_struct.num_cases
    )
    self.null_loglikelihood = -jnp.sum(
        self.case_weights * jnp.log(alternatives_per_case)
    )
    self.mcfadden_r2 = 1.0 - self.loglikelihood / self.null_loglikelihood

    if not self.converged:
        logger.warning(
            "The optimization did not converge after %s iterations. Message: %s",
            self.total_iter,
            optim_res.message,
        )

abic property

Deprecated alias for :attr:adjusted_bic.

convergence property

Deprecated alias for :attr:converged.

covariance property

Deprecated alias for :attr:cov_matrix.

covariance_available property

Report whether a usable covariance matrix was estimated.

flat_params property

Latent parameter vector, aligned with :attr:latent_cov_matrix.

coefficient_table()

Return conditional-logit coefficients with presentation labels.

Returns:

Type Description
DataFrame

One row per alternative-specific variable with raw variable names, display labels, estimates, standard errors, z-values, and p-values.

Source code in src/lcl/conditional_logit.py
def coefficient_table(self) -> pl.DataFrame:
    """Return conditional-logit coefficients with presentation labels.

    Returns
    -------
    pl.DataFrame
        One row per alternative-specific variable with raw variable names,
        display labels, estimates, standard errors, z-values, and p-values.
    """
    rows = []
    for coeff_idx, variable in enumerate(self.model.case_varnames):
        rows.append(
            {
                "variable": variable,
                "label": self.model.variable_label(variable),
                "estimate": float(self.coeff_[coeff_idx]),
                "std_error": float(self.stderr[coeff_idx]),
                "z_value": float(self.zvalues[coeff_idx]),
                "p_value": float(self.pvalues[coeff_idx]),
            }
        )
    return pl.DataFrame(rows)

diagnostics()

Return convergence, fit, score, and information diagnostics.

Source code in src/lcl/conditional_logit.py
def diagnostics(self) -> LCLDiagnostics:
    """Return convergence, fit, score, and information diagnostics."""
    rows: list[dict[str, object]] = [
        {
            "section": "fit",
            "check": "converged",
            "value": bool(self.converged),
            "status": "ok" if self.converged else "warning",
            "message": "Conditional-logit optimizer convergence flag.",
        },
        {
            "section": "fit",
            "check": "observed_score_max",
            "value": self.observed_score_max,
            "status": "warning" if self.observed_score_max > 1e-4 else "ok",
            "message": "Maximum absolute component of the weighted score.",
        },
        {
            "section": "fit",
            "check": "mcfadden_r2",
            "value": float(self.mcfadden_r2),
            "status": "ok",
            "message": "McFadden pseudo-R-squared against equal choice shares.",
        },
    ]
    if self.information_diagnostics is not None:
        info = self.information_diagnostics
        rows.extend(
            [
                {
                    "section": "inference",
                    "check": "information_rank",
                    "value": info.rank,
                    "status": "warning" if info.rank_deficient else "ok",
                    "message": "Numerical rank of the information matrix.",
                },
                {
                    "section": "inference",
                    "check": "information_condition_number",
                    "value": info.condition_number,
                    "status": (
                        "warning"
                        if not info.positive_definite
                        or info.condition_number > 1e12
                        else "ok"
                    ),
                    "message": "Condition number of the information matrix.",
                },
            ]
        )
    return LCLDiagnostics(pl.DataFrame(rows))

loglik(data, *, per_case=False)

Score observed choices with the fitted conditional-logit encoder.

Source code in src/lcl/conditional_logit.py
def loglik(self, data: Any, *, per_case: bool = False) -> float | pl.DataFrame:
    """Score observed choices with the fitted conditional-logit encoder."""
    parsed = self.model._transform_data(data, require_choice=True)
    data_struct, weights, _ = self.model._setup_data(parsed)
    differenced = _diff_unchosen_chosen(data_struct)
    log_probabilities, _ = _diff_logit_components(
        differenced.X,
        self.coeff_,
        differenced.cases,
        differenced.num_cases,
    )
    if not per_case:
        return float(jnp.sum(log_probabilities * weights))
    if parsed.original_cases is None:
        raise ValueError("Original case identifiers are unavailable.")
    first_case_rows = onp.asarray(data_struct.cases) != onp.roll(
        onp.asarray(data_struct.cases), 1
    )
    first_case_rows[0] = True
    return pl.DataFrame(
        {
            "case": onp.asarray(parsed.original_cases[first_case_rows]),
            "log_likelihood": onp.asarray(log_probabilities),
        }
    )

parameter_names()

Return names aligned with covariance rows and columns.

Source code in src/lcl/conditional_logit.py
def parameter_names(self) -> list[str]:
    """Return names aligned with covariance rows and columns."""
    return list(self.model.case_varnames)

predict(data, *, alts_col=None, cases_col=None, panels_col=None, panel_weights=None)

Predict conditional choice probabilities for a given set of alternatives.

Parameters:

Name Type Description Default
data DataFrame | DataFrame

The counterfactual dataset. Must contain all variables specified in the original model (including expanded dummy columns if a formula was used).

required
alts_col str

Name of the column containing alternative identifiers.

None
cases_col str

Name of the column grouping observations into distinct choice situations.

None
panels_col str | None

Name of the column mapping observations to specific decision-makers.

None

Returns:

Type Description
DataFrame

DataFrame containing the computed out-of-sample choice probabilities.

Source code in src/lcl/conditional_logit.py
def predict(
    self,
    data: Any,
    *,
    alts_col: str | None = None,
    cases_col: str | None = None,
    panels_col: str | None = None,
    panel_weights: str | Mapping[object, float] | Sequence[float] | None = None,
) -> CLPrediction:
    """Predict conditional choice probabilities for a given set of alternatives.

    Parameters
    ----------
    data : pd.DataFrame | pl.DataFrame
        The counterfactual dataset. Must contain all variables specified in
        the original model (including expanded dummy columns if a formula was used).
    alts_col : str
        Name of the column containing alternative identifiers.
    cases_col : str
        Name of the column grouping observations into distinct choice situations.
    panels_col : str | None, optional
        Name of the column mapping observations to specific decision-makers.

    Returns
    -------
    pl.DataFrame
        DataFrame containing the computed out-of-sample choice probabilities.
    """
    if alts_col is not None or cases_col is not None:
        warnings.warn(
            "alts_col and cases_col are no longer needed by predict(); the "
            "fitted encoder supplies identifier columns.",
            DeprecationWarning,
            stacklevel=2,
        )
    parsed = self.model._transform_data(data)
    if (
        parsed.original_alts is None
        or parsed.original_cases is None
        or parsed.original_panels is None
    ):
        raise ValueError("Original prediction identifiers are unavailable.")
    data_struct, _, _ = self.model._setup_data(parsed)
    probs, logsum = _choice_probabilities_and_logsum(
        data_struct.X,
        self.coeff_[:, None],
        data_struct.cases,
        data_struct.num_cases,
    )
    predicted_probs = pl.DataFrame(
        {
            "panels": parsed.original_panels,
            "cases": parsed.original_cases,
            "alts": parsed.original_alts,
            "choice_probs": onp.asarray(probs[:, 0], dtype=onp.float64),
        }
    )
    first_case_rows = onp.asarray(data_struct.cases) != onp.roll(
        onp.asarray(data_struct.cases), 1
    )
    first_case_rows[0] = True
    marginal_utility_income = (
        1.0
        if self.model.numeraire_idx is None
        else float(-self.coeff_[self.model.numeraire_idx])
    )
    surplus = pl.DataFrame(
        {
            "panels": onp.asarray(parsed.original_panels[first_case_rows]),
            "cases": onp.asarray(parsed.original_cases[first_case_rows]),
            "surplus": onp.asarray(logsum[:, 0] / marginal_utility_income),
        }
    )
    if data_struct.panels is None or data_struct.num_panels is None:
        raise ValueError("Panel identifiers are required for prediction.")
    first_panel_rows = onp.asarray(data_struct.panels) != onp.roll(
        onp.asarray(data_struct.panels), 1
    )
    first_panel_rows[0] = True
    panel_ids = onp.asarray(parsed.original_panels[first_panel_rows])
    wtp_variables = [
        variable
        for idx, variable in enumerate(self.model.case_varnames)
        if idx != self.model.numeraire_idx
    ]
    if self.model.numeraire_idx is None:
        wtp_values = onp.empty((data_struct.num_panels, 0))
        wtp_variables = []
    else:
        ratios = (
            onp.delete(onp.asarray(self.coeff_), self.model.numeraire_idx)
            / marginal_utility_income
        )
        wtp_values = onp.repeat(ratios[None, :], data_struct.num_panels, axis=0)
    wtp_by_panel = pl.DataFrame(wtp_values, schema=wtp_variables).with_columns(
        pl.Series("panels", panel_ids)
    )
    encoder = self.model._encoder
    if encoder is None:
        raise ValueError("The fitted data encoder is unavailable.")
    raw_data = _coerce_frame(data).sort(
        list(
            dict.fromkeys([encoder.panels_col, encoder.cases_col, encoder.alts_col])
        )
    )
    resolved_panel_weights = resolve_panel_weights(
        panel_weights, panel_ids, raw_data, encoder.panels_col
    )
    return CLPrediction(
        predicted_probs_df=predicted_probs,
        surplus_df=surplus,
        wtp_alt_vars_by_panel_df=wtp_by_panel,
        predict_data=data_struct,
        results=self,
        class_probs_by_panel=jnp.ones((data_struct.num_panels, 1)),
        partition_data_df=None,
        original_alts=parsed.original_alts,
        original_cases=parsed.original_cases,
        original_panels=parsed.original_panels,
        raw_prediction_data=raw_data,
        panel_weights=resolved_panel_weights,
    )

summarize(num_decimals=3, *, show=True)

Alias for :meth:summarize_betas.

Source code in src/lcl/conditional_logit.py
def summarize(self, num_decimals: int = 3, *, show: bool = True) -> pl.DataFrame:
    """Alias for :meth:`summarize_betas`."""
    return self.summarize_betas(num_decimals=num_decimals, show=show)

summarize_betas(header=('Variable', 'Estimate', 'Std. Error'), num_decimals=3, *, show=True)

Print and return a table of parameter estimates and standard errors.

Parameters:

Name Type Description Default
header tuple[str, str, str]

Column labels used for printed LaTeX and terminal tables.

("Variable", "Estimate", "Std. Error")
num_decimals int

Number of decimal places used in printed tables.

3
show bool

Emit LaTeX and terminal renderings. Set to False for computation-only use.

True

Returns:

Type Description
DataFrame

Tidy coefficient table. The variable column preserves raw model names, while label contains presentation labels.

Source code in src/lcl/conditional_logit.py
def summarize_betas(
    self,
    header: tuple[str, str, str] = ("Variable", "Estimate", "Std. Error"),
    num_decimals: int = 3,
    *,
    show: bool = True,
) -> pl.DataFrame:
    """Print and return a table of parameter estimates and standard errors.

    Parameters
    ----------
    header : tuple[str, str, str], default=("Variable", "Estimate", "Std. Error")
        Column labels used for printed LaTeX and terminal tables.
    num_decimals : int, default=3
        Number of decimal places used in printed tables.
    show : bool, default=True
        Emit LaTeX and terminal renderings. Set to ``False`` for computation-only
        use.

    Returns
    -------
    pl.DataFrame
        Tidy coefficient table.  The ``variable`` column preserves raw model
        names, while ``label`` contains presentation labels.
    """
    table_df = self.coefficient_table()
    if show:
        log_or_print(
            logger,
            "%s",
            format_cl_coefficients(table_df, header, num_decimals),
        )
    return table_df

lcl.results.CLPrediction(predicted_probs_df, surplus_df, wtp_alt_vars_by_panel_df, predict_data, results, class_probs_by_panel=None, class_probabilities_source='prior', partition_data_df=None, original_alts=None, original_cases=None, original_panels=None, raw_prediction_data=None, panel_weights=None, past_diff_unchosen_chosen=None, past_data=None)

Bases: _PredictionBase

Conditional-logit prediction with WTP and elasticity diagnostics.

Source code in src/lcl/_prediction.py
def __init__(
    self,
    predicted_probs_df: pl.DataFrame,
    surplus_df: pl.DataFrame,
    wtp_alt_vars_by_panel_df: pl.DataFrame,
    predict_data: Data,
    results: Any,
    class_probs_by_panel: Float64[Array, "panels classes"] | None = None,
    class_probabilities_source: str = "prior",
    partition_data_df: pl.DataFrame | None = None,
    original_alts: Any | None = None,
    original_cases: Any | None = None,
    original_panels: Any | None = None,
    raw_prediction_data: pl.DataFrame | None = None,
    panel_weights: Sequence[float] | onp.ndarray | None = None,
    past_diff_unchosen_chosen: Any | None = None,
    past_data: Data | None = None,
) -> None:
    """Store prediction outputs and references needed for post-processing.

    Parameters
    ----------
    predicted_probs_df : pl.DataFrame
        Long-format alternative choice probabilities.
    surplus_df : pl.DataFrame
        Case-level consumer surplus estimates.
    wtp_alt_vars_by_panel_df : pl.DataFrame
        Panel-level marginal WTP values for non-numeraire variables.
    predict_data : :class:`~lcl._struct.Data`
        Encoded data used to generate the predictions.
    results : Any
        Parent results object. Kept broad to support both latent-class and
        conditional-logit result containers without a circular import.
    class_probs_by_panel : Float64[Array, "panels classes"] | None, optional
        Class probabilities used to marginalize class-specific predictions.
    class_probabilities_source : str, default="prior"
        ``"posterior"`` when prediction used historical choices, otherwise
        ``"prior"``.
    partition_data_df : pl.DataFrame | None, optional
        Panel-level raw prediction columns available for WTP partitions.
    """
    self.predicted_probs = predicted_probs_df
    self.surplus_units = (
        "money"
        if getattr(results.model, "numeraire_idx", None) is not None
        else "utils"
    )
    self.surplus = surplus_df.with_columns(
        pl.lit(self.surplus_units).alias("surplus_units")
    )
    self.wtp_alt_vars_by_panel = wtp_alt_vars_by_panel_df
    self.predict_data = predict_data
    self.results = results
    self.class_probs_by_panel = class_probs_by_panel
    self.class_probabilities_source = class_probabilities_source
    self.partition_data = partition_data_df
    self.original_alts = (
        onp.asarray(original_alts)
        if original_alts is not None
        else onp.asarray(predict_data.alts)
    )
    self.original_cases = (
        onp.asarray(original_cases)
        if original_cases is not None
        else onp.asarray(predict_data.cases)
    )
    self.original_panels = (
        onp.asarray(original_panels)
        if original_panels is not None
        else (
            None
            if predict_data.panels is None
            else onp.asarray(predict_data.panels)
        )
    )
    self.raw_prediction_data = raw_prediction_data
    # Retained so willingness to pay computed from Bayesian-updated class
    # membership can be differentiated through that update rather than
    # treating the posterior as a fixed constant.
    self.past_diff_unchosen_chosen = past_diff_unchosen_chosen
    self.past_data = past_data
    num_panels = predict_data.num_panels
    if num_panels is None:
        raise ValueError("Panel identifiers are required for prediction.")
    weights = (
        onp.ones(num_panels, dtype=onp.float64)
        if panel_weights is None
        else onp.asarray(panel_weights, dtype=onp.float64)
    )
    if weights.shape != (num_panels,):
        raise ValueError(
            "panel_weights must contain one value per prediction panel."
        )
    if not onp.all(onp.isfinite(weights)) or onp.any(weights < 0.0):
        raise ValueError("panel_weights must be finite and nonnegative.")
    if not onp.any(weights > 0.0):
        raise ValueError("At least one panel weight must be positive.")
    self.panel_weights = weights

compute_wtp(target=None, **kwargs)

Alias for :meth:wtp.

Source code in src/lcl/_prediction.py
def compute_wtp(self, target: str | None = None, **kwargs: Any) -> pl.DataFrame:
    """Alias for :meth:`wtp`."""
    return self.wtp(target, **kwargs)

denominator_diagnostics()

Report the homogeneous WTP denominator and configured floor.

Source code in src/lcl/_prediction.py
def denominator_diagnostics(self) -> pl.DataFrame:
    """Report the homogeneous WTP denominator and configured floor."""
    cost_idx = getattr(self.results.model, "numeraire_idx", None)
    if cost_idx is None:
        raise ValueError("A numeraire must be defined to compute diagnostics.")
    denominator = float(-self.results.coeff_[cost_idx])
    return pl.DataFrame(
        {
            "class": [0],
            "denominator": [self.results.model.numeraire],
            "denominator_value": [denominator],
            "abs_denominator": [abs(denominator)],
            "min_abs_floor": [self.results.model.numeraire_min_abs],
        }
    )

wtp(target=None, *, se='delta', bootstrap_draws=500, bootstrap_seed=0)

Return homogeneous WTP ratios with delta or parametric-bootstrap SEs.

Both methods work in the unconstrained parameterization and apply the softplus transform inside the target function. Drawing structural coefficients directly would put mass on a positive numeraire coefficient -- a region the constraint excludes -- and the resulting ratios have no finite variance to summarize.

Parameters:

Name Type Description Default
target str | None

Restrict the table to one non-numeraire variable.

None
se (delta, bootstrap, none)

Standard-error method.

"delta"
bootstrap_draws int

Number of asymptotic parameter draws for se="bootstrap".

500
bootstrap_seed int

Reproducible seed for those draws.

0

Returns:

Type Description
DataFrame

One row per variable with its tradeoff ratio and standard error.

Source code in src/lcl/_prediction.py
def wtp(
    self,
    target: str | None = None,
    *,
    se: Literal["delta", "bootstrap", "none"] = "delta",
    bootstrap_draws: int = 500,
    bootstrap_seed: int = 0,
) -> pl.DataFrame:
    """Return homogeneous WTP ratios with delta or parametric-bootstrap SEs.

    Both methods work in the unconstrained parameterization and apply the
    softplus transform inside the target function.  Drawing structural
    coefficients directly would put mass on a positive numeraire coefficient
    -- a region the constraint excludes -- and the resulting ratios have no
    finite variance to summarize.

    Parameters
    ----------
    target : str | None, optional
        Restrict the table to one non-numeraire variable.
    se : {"delta", "bootstrap", "none"}, default="delta"
        Standard-error method.
    bootstrap_draws : int, default=500
        Number of asymptotic parameter draws for ``se="bootstrap"``.
    bootstrap_seed : int, default=0
        Reproducible seed for those draws.

    Returns
    -------
    pl.DataFrame
        One row per variable with its tradeoff ratio and standard error.
    """
    if se not in {"delta", "bootstrap", "none"}:
        raise ValueError("se must be 'delta', 'bootstrap', or 'none'.")
    cost_idx = getattr(self.results.model, "numeraire_idx", None)
    if cost_idx is None:
        raise ValueError("A numeraire must be defined to compute WTP.")
    target_indices = [
        idx
        for idx, variable in enumerate(self.results.model.case_varnames)
        if idx != cost_idx and (target is None or variable == target)
    ]
    if target is not None and not target_indices:
        raise ValueError(
            f"Variable {target!r} was not found in the utility design."
        )
    selector = jnp.asarray(target_indices)

    def ratio_function(latent: Array) -> Array:
        """Map latent coefficients to structural WTP ratios."""
        structural = _to_structural_betas(
            latent,
            self.results.model.numeraire_idx,
            self.results.model.numeraire_min_abs,
        )
        return structural[selector] / (-structural[cost_idx])

    latent = jnp.asarray(self.results.flat_params)
    ratios = ratio_function(latent)
    if se == "none":
        standard_errors = jnp.full_like(ratios, jnp.nan)
    elif se == "delta":
        _, standard_errors = self.results._apply_delta_method(
            ratio_function, latent
        )
    else:
        standard_errors = self.results._parametric_bootstrap_se(
            ratio_function, latent, draws=bootstrap_draws, seed=bootstrap_seed
        )

    rows = []
    for output_idx, variable_idx in enumerate(target_indices):
        variable = self.results.model.case_varnames[variable_idx]
        rows.append(
            {
                "variable": variable,
                "label": self.results.model.variable_label(variable),
                "denominator": self.results.model.numeraire,
                "tradeoff": float(ratios[output_idx]),
                "std_error": float(standard_errors[output_idx]),
                "se_method": se,
            }
        )
    return pl.DataFrame(rows)

wtp_by_class(target=None)

Return WTP with a single homogeneous class label.

Source code in src/lcl/_prediction.py
def wtp_by_class(self, target: str | None = None) -> pl.DataFrame:
    """Return WTP with a single homogeneous class label."""
    return self.wtp(target, se="none").with_columns(pl.lit(0).alias("class"))