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(newton_decrement_tol=1e-6),
        inference=InferenceOptions(covariance="clustered"),
    ),
)

coefficient_table = results.summarize_betas(show=False)
held_out_ll = results.loglik(test_data, weights="survey_weight")

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. loglik accepts the same weights forms; omitting scoring weights gives equal weight to every case. loglik(data, per_case=True) includes both panel and case IDs.

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 negatively 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 methods shared with latent-class prediction. CL wtp(target), compute_wtp(target), and tradeoff(target) return the same mean-WTP table; LCL compute_wtp accepts partition requests. See the API contracts guide for the complete comparison.

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()

prediction.marginal_wtp("time") evaluates each offered profile; prediction.wtp("time") averages equally over profiles within occasions, then occasions within consumers, and uses panel_weights across consumers. Both include raw-attribute interactions and transformations. Monetary WTP and welfare require a numeraire that enters once, linearly, without additional price terms. See the prediction and welfare guide for the estimands, interpretation, and inference assumptions.

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
numeraire_min_abs float

Positive minimum absolute magnitude of the constrained coefficient.

1e-5

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__()
    NegativeCoefficient(min_abs=numeraire_min_abs)
    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, diagnostics=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 pandas.DataFrame, polars.DataFrame, or mapping

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

(alt_vars,) vector of economic coefficients in expanded design-column order. The solver projects constrained entries onto their bounds. Omission starts at zero, projected onto those same bounds.

None
options Options | None

Complete configuration. Conditional logit uses optimization, inference, and diagnostics.check_collinearity; it has no EM stage. Do not combine with individual option arguments.

None
optimization_options OptimizationOptions | None

Preferred safeguarded exact-Newton settings.

None
inference InferenceOptions | None

Preferred covariance and standard-error settings.

None
diagnostics DiagnosticsOptions | None

Controls information-rank reporting through check_collinearity. Membership and class-specific warning settings apply only to LCL.

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]
        | CaseWeightsInput
        | None
    ) = None,
    weight_type: str = "probability",
    init_beta: InitialCoefficientsInput | None = None,
    options: Options | None = None,
    optimization_options: OptimizationOptions | None = None,
    inference: InferenceOptions | None = None,
    diagnostics: DiagnosticsOptions | 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 : pandas.DataFrame, polars.DataFrame, or mapping
        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
        ``(alt_vars,)`` vector of economic coefficients in expanded design-column
        order. The solver projects constrained entries onto their bounds.
        Omission starts at zero, projected onto those same bounds.
    options : Options | None, optional
        Complete configuration. Conditional logit uses ``optimization``,
        ``inference``, and ``diagnostics.check_collinearity``; it has no EM
        stage. Do not combine with individual option arguments.
    optimization_options : OptimizationOptions | None, optional
        Preferred safeguarded exact-Newton settings.
    inference : InferenceOptions | None, optional
        Preferred covariance and standard-error settings.
    diagnostics : DiagnosticsOptions | None, optional
        Controls information-rank reporting through ``check_collinearity``.
        Membership and class-specific warning settings apply only to LCL.

    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,
        diagnostics=diagnostics,
    )
    optimization_options = resolved_options.optimization
    inference = resolved_options.inference
    if inference.boundary != "strict":
        raise ValueError(
            "Conditional/projected boundary inference is supported only for LCL models."
        )

    # 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 and not inference.skip:
        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,
        negative_bound=self._negative_bound,
        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)

    result = 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,
        diagnostics_config=resolved_options.diagnostics,
    )
    self.convergence = result.converged
    return result

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, diagnostics_config=None)

Post-estimation results and inference container for Conditional Logit.

Coefficients, covariance, and prediction derivatives share one parameterization. Ordinary covariance is unavailable when a coefficient bound is binding.

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
diagnostics_config DiagnosticsOptions | None

Diagnostic reporting switches; copied when results are constructed.

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: CaseWeightsInput,
    weight_type: str = "probability",
    cluster_of_cases: Integer[ArrayLike, "cases"] | None = None,
    num_clusters: int | None = None,
    diagnostics_config: DiagnosticsOptions | 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``.
    diagnostics_config : DiagnosticsOptions | None, optional
        Diagnostic reporting switches; copied when results are constructed.
    """
    self.model = model_spec
    self.data = data_struct
    self.inference = replace(inference)
    self.diagnostics_config = (
        DiagnosticsOptions()
        if diagnostics_config is None
        else replace(diagnostics_config)
    )
    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.coeff_ = optim_res.params
    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)
        covariance = 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,
            )
            covariance = _robust_covariance(
                self.hess_inv, grad_g, inference.finite_sample_correction
            )
        else:
            # Standard Huber-White Robust Standard Errors
            covariance = _robust_covariance(
                self.hess_inv,
                optim_res.grad_n,
                inference.finite_sample_correction,
                weights=self.case_weights,
                weight_type=self.weight_type,
            )
    else:
        covariance = self.hess_inv

    bounds = self.model._negative_bound.upper_bounds(self.coeff_)
    if (
        not inference.skip
        and bounds is not None
        and bool(jnp.any(self.coeff_ >= bounds - BOUNDARY_DISTANCE_TOL))
    ):
        logger.warning(
            "Ordinary covariance is unavailable at a binding coefficient bound."
        )
        covariance = jnp.full_like(covariance, jnp.nan)
    self.cov_matrix = covariance
    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:
        # Zero is outside the coefficient's allowed range, so there is no
        # ordinary zero-null test for this parameter.
        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(
                projected_score(
                    jnp.sum(optim_res.grad_n * self.case_weights[:, None], axis=0),
                    self.coeff_,
                    bounds,
                )
            )
        )
    )

    # 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

Coefficient vector, aligned with :attr: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
        and self.diagnostics_config.check_collinearity
    ):
        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, weights=None)

Score observed choices with the fitted encoder.

Parameters:

Name Type Description Default
data object

Long-format observed choices using the fitted column names.

required
per_case bool

Return weighted contributions with original panel and case IDs.

False
weights str, mapping, array-like, or None

Scoring weights with the same alignment rules as :meth:ConditionalLogit.fit. Omission scores all cases equally; training weights are not reused.

None

Returns:

Type Description
float or DataFrame

Total log likelihood, or a table with panel, case, and log_likelihood columns. Joint IDs distinguish repeated case IDs.

Source code in src/lcl/conditional_logit.py
def loglik(
    self,
    data: Any,
    *,
    per_case: bool = False,
    weights: str | Mapping[object, float | int] | CaseWeightsInput | None = None,
) -> float | pl.DataFrame:
    """Score observed choices with the fitted encoder.

    Parameters
    ----------
    data : object
        Long-format observed choices using the fitted column names.
    per_case : bool, default=False
        Return weighted contributions with original panel and case IDs.
    weights : str, mapping, array-like, or None, optional
        Scoring weights with the same alignment rules as :meth:`ConditionalLogit.fit`.
        Omission scores all cases equally; training weights are not reused.

    Returns
    -------
    float or pl.DataFrame
        Total log likelihood, or a table with ``panel``, ``case``, and
        ``log_likelihood`` columns. Joint IDs distinguish repeated case IDs.
    """
    parsed = self.model._transform_data(data, require_choice=True)
    encoder = self.model._encoder
    if encoder is None:
        raise ValueError("The fitted data encoder is unavailable.")
    aligned_weights = self.model._resolve_case_weights(
        data,
        parsed,
        weights,
        cases_col=encoder.cases_col,
        panels_col=encoder.panels_col,
    )
    data_struct, weights_arr, _ = self.model._setup_data(
        parsed, weights=aligned_weights
    )
    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_arr))
    if parsed.original_cases is None or parsed.original_panels 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(
        {
            "panel": onp.asarray(parsed.original_panels[first_case_rows]),
            "case": onp.asarray(parsed.original_cases[first_case_rows]),
            "log_likelihood": onp.asarray(log_probabilities * weights_arr),
        }
    )

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 pandas.DataFrame, polars.DataFrame, or mapping

Counterfactual raw data. The fitted encoder reuses formula transforms and categorical levels; expanded dummy columns need not be supplied.

required
alts_col str | None

Deprecated redundant identifiers. If supplied, they must match the fitted encoder; rename input columns to predict with the fitted names.

None
cases_col str | None

Deprecated redundant identifiers. If supplied, they must match the fitted encoder; rename input columns to predict with the fitted names.

None
panels_col str | None

Deprecated redundant identifiers. If supplied, they must match the fitted encoder; rename input columns to predict with the fitted names.

None
panel_weights str, mapping, sequence, or array

Panel aggregation weights: a constant-per-panel data column, mapping by panel ID, or vector in sorted unique prediction-panel order. These weights affect aggregate summaries, not individual probabilities.

None

Returns:

Type Description
CLPrediction

Probabilities in predicted_probs, plus surplus, WTP, elasticities, and aggregate inference methods.

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] | PanelWeightsInput | None = None,
) -> CLPrediction:
    """Predict conditional choice probabilities for a given set of alternatives.

    Parameters
    ----------
    data : pandas.DataFrame, polars.DataFrame, or mapping
        Counterfactual raw data. The fitted encoder reuses formula transforms
        and categorical levels; expanded dummy columns need not be supplied.
    alts_col, cases_col, panels_col : str | None, optional
        Deprecated redundant identifiers. If supplied, they must match the
        fitted encoder; rename input columns to predict with the fitted names.
    panel_weights : str, mapping, sequence, or array, optional
        Panel aggregation weights: a constant-per-panel data column, mapping
        by panel ID, or vector in sorted unique prediction-panel order. These
        weights affect aggregate summaries, not individual probabilities.

    Returns
    -------
    CLPrediction
        Probabilities in ``predicted_probs``, plus surplus, WTP, elasticities,
        and aggregate inference methods.
    """
    encoder = self.model._encoder
    if encoder is None:
        raise ValueError("The fitted data encoder is unavailable.")
    for name, value in (
        ("alts_col", alts_col),
        ("cases_col", cases_col),
        ("panels_col", panels_col),
    ):
        if value is not None and value != getattr(encoder, name):
            raise ValueError(
                f"{name} must match the fitted encoder ({getattr(encoder, name)!r})."
            )
    if any(value is not None for value in (alts_col, cases_col, panels_col)):
        warnings.warn(
            "alts_col, cases_col, and panels_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 = _aligned_raw_prediction_data(data, parsed, encoder)
    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: RowIdsInput | None = None,
    original_cases: RowIdsInput | None = None,
    original_panels: RowIdsInput | None = None,
    raw_prediction_data: pl.DataFrame | None = None,
    panel_weights: PanelWeightsInput | None = None,
    past_diff_unchosen_chosen: DiffUnchosenChosen | 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._money_metric_valid = numeraire_enters_linearly(results.model)
    if not self._money_metric_valid:
        self.surplus_units = "undefined"
        surplus_df = surplus_df.with_columns(pl.lit(float("nan")).alias("surplus"))
    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
    if not self._money_metric_valid:
        self.wtp_alt_vars_by_panel = self.wtp_alt_vars_by_panel.with_columns(
            [
                pl.lit(float("nan")).alias(c)
                for c in self.wtp_alt_vars_by_panel.columns
                if c != "panels"
            ]
        )
    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 WTP denominator, floor, SE, and Gaussian crossing probabilities.

The probability above the coefficient bound drives the 0.001 bootstrap screen; the probability above zero describes sign uncertainty. Neither establishes finite ratio moments. Unavailable covariance gives NaNs.

Source code in src/lcl/_prediction.py
def denominator_diagnostics(self) -> pl.DataFrame:
    """Report the WTP denominator, floor, SE, and Gaussian crossing probabilities.

    The probability above the coefficient bound drives the 0.001 bootstrap
    screen; the probability above zero describes sign uncertainty. Neither
    establishes finite ratio moments. Unavailable covariance gives NaNs.
    """
    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)],
            **self._denominator_uncertainty(),
            "min_abs_floor": [self.results.model.numeraire_min_abs],
        }
    )

tradeoff(target=None, **kwargs)

Alias for :meth:wtp, with the same conditional-logit arguments.

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

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

Return mean marginal WTP with delta or parametric-bootstrap SEs.

Both methods use the coefficient vector and its covariance directly. Gaussian simulation screens the fitted probability above the numeraire bound at 0.001, independently of seed and draw count. Passing this screen does not guarantee finite ratio moments or boundary-aware inference.

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 requested variable with mean marginal WTP and its SE. Raw variables include interactions and transforms. Values average equally over available profiles within cases, cases within consumers, and by panel weights over consumers. Expanded-column targets instead hold other design columns fixed.

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 mean marginal WTP with delta or parametric-bootstrap SEs.

    Both methods use the coefficient vector and its covariance directly.
    Gaussian simulation screens the fitted probability above the numeraire
    bound at 0.001, independently of seed and draw count. Passing this screen
    does not guarantee finite ratio moments or boundary-aware inference.

    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 requested variable with mean marginal WTP and its SE.
        Raw variables include interactions and transforms. Values average
        equally over available profiles within cases, cases within consumers,
        and by panel weights over consumers. Expanded-column targets instead
        hold other design columns fixed.
    """
    self._require_valid_numeraire()
    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.")
    targets = (
        [target]
        if target is not None
        else [
            variable
            for idx, variable in enumerate(self.results.model.case_varnames)
            if idx != cost_idx
        ]
    )
    if not targets:
        return pl.DataFrame(
            schema={
                "variable": pl.String,
                "label": pl.String,
                "denominator": pl.String,
                "tradeoff": pl.Float64,
                "std_error": pl.Float64,
                "se_method": pl.String,
            }
        )
    normalized_weights = jnp.asarray(self.panel_weights / self.panel_weights.sum())
    derivatives = jnp.stack(
        [
            jnp.sum(
                self._wtp_panel_derivative(variable) * normalized_weights[:, None],
                axis=0,
            )
            for variable in targets
        ]
    )

    def ratio_function(
        coefficients: Float64[Array, "alt_vars"],
    ) -> Float64[Array, "targets"]:
        """Compute WTP ratios from economic coefficients."""
        return (derivatives @ coefficients) / (-coefficients[cost_idx])

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

    rows = []
    for output_idx, variable in enumerate(targets):
        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"))