Skip to content

Conditional logit

The standard McFadden conditional logit, which estimates single vector of taste parameters across the entire sample. Useful both as a baseline against richer models and as the inner kernel of the latent-class M-step.

Model

lcl.conditional_logit.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, formula=None, utility_formula=None, choice_col=None, case_varnames=None, weights=None, init_beta=None, mle_config=None, error_config=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
formula str | None

Backward-compatible Formulaic string, for example "choice ~ price + C(brand)".

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
weights ArrayLike | None

(Nc,) vector of choice situation importance weights.

None
init_beta ArrayLike | None

(K,) vector of initial taste parameters.

None
mle_config :class:`~lcl._struct.MleConfig`

Configuration for the L-BFGS optimization routine.

None
error_config :class:`~lcl._struct.ErrorConfig`

Configuration determining the robust covariance estimation strategy.

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,
    formula: str | None = None,
    utility_formula: str | None = None,
    choice_col: str | None = None,
    case_varnames: Sequence[str] | None = None,
    weights: ArrayLike | None = None,
    init_beta: ArrayLike | None = None,
    mle_config: MleConfig | None = None,
    error_config: ErrorConfig | 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.
    formula : str | None, optional
        Backward-compatible Formulaic string, for example
        ``"choice ~ price + C(brand)"``.
    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.
    weights : ArrayLike | None, optional
        ``(Nc,)`` vector of choice situation importance weights.
    init_beta : ArrayLike | None, optional
        ``(K,)`` vector of initial taste parameters.
    mle_config : :class:`~lcl._struct.MleConfig`, optional
        Configuration for the L-BFGS optimization routine.
    error_config : :class:`~lcl._struct.ErrorConfig`, optional
        Configuration determining the robust covariance estimation strategy.

    Returns
    -------
    :class:`~lcl.conditional_logit.CLResults`
        Results container housing coefficients, robust standard errors, and fit statistics.
    """
    if mle_config is None:
        mle_config = MleConfig()
    if error_config is None:
        error_config = ErrorConfig()

    # 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,
        formula=formula,
        utility_formula=utility_formula,
        membership_formula=None,
        choice_col=choice_col,
        case_varnames=case_varnames,
        dem_varnames=None,  # Standard CL does not take demographics
        dems_data=None,
    )

    self._pre_fit(parsed_data.case_varnames, None, self.numeraire)
    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
    data_struct, weights_arr, init_beta_arr = self._setup_data(
        parsed=parsed_data,
        weights=weights,
        init_beta=init_beta,
    )

    diff_unchosen_chosen = _diff_unchosen_chosen(data_struct)

    # Estimate the conditional logit model
    optim_res = _minimize(
        _loglik_gradient,
        init_beta_arr,
        args=(diff_unchosen_chosen, weights_arr),
        mle_config=mle_config,
        numeraire_idx=self.numeraire_idx,
        numeraire_min_abs=self.numeraire_min_abs,
    )

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

    return CLResults(
        model_spec=self,
        optim_res=optim_res,
        data_struct=data_struct,
        error_config=error_config,
        estim_time_sec=estim_time_sec,
        has_panels=panels_col is not None,
    )

Results

lcl.conditional_logit.CLResults(model_spec, optim_res, data_struct, error_config, estim_time_sec, has_panels)

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
error_config :class:`~lcl._struct.ErrorConfig`

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
Source code in src/lcl/conditional_logit.py
def __init__(
    self,
    model_spec: ConditionalLogit,
    optim_res: OptimizeResult,
    data_struct: Data,
    error_config: ErrorConfig,
    estim_time_sec: float,
    has_panels: bool,
) -> 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.
    error_config : :class:`~lcl._struct.ErrorConfig`
        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.
    """
    self.model = model_spec
    self.data = data_struct
    self.convergence = 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

    if error_config.skip_std_errs:
        self.hess_inv = jnp.eye(len(optim_res.params))

    # Covariance Calculation (Cluster-Robust, Standard Robust, or Unadjusted)
    if error_config.robust:
        if (
            has_panels
            and data_struct.panels_of_cases is not None
            and data_struct.num_panels is not None
        ):
            # Cluster-robust standard errors
            grad_g = segment_sum(
                optim_res.grad_n,
                data_struct.panels_of_cases,
                num_segments=data_struct.num_panels,
            )
            B = grad_g.T @ grad_g
            robust_cov = self.hess_inv @ B @ self.hess_inv
            G = data_struct.num_panels
            self.covariance = robust_cov * (G / (G - 1))
        else:
            # Standard Huber-White Robust Standard Errors
            self.covariance = _robust_covariance(self.hess_inv, optim_res.grad_n)
    else:
        self.covariance = self.hess_inv

    # Apply delta method for standard errors if numeraire (softplus) is used
    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 @ self.covariance @ jac.T
        self.stderr = jnp.sqrt(jnp.diag(struct_cov))
    else:
        self.stderr = jnp.sqrt(jnp.diag(self.covariance))

    self.zvalues = self.coeff_ / self.stderr
    self.pvalues = 2 * t.cdf(-onp.abs(self.zvalues), df=data_struct.num_cases)
    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.total_fun_eval = optim_res.nfev
    self.grad_n = optim_res.grad_n

    # Information criteria
    self.aic = 2 * len(self.coeff_) - 2 * self.loglikelihood
    self.caic = (
        len(self.coeff_) * (jnp.log(data_struct.num_cases) + 1)
        - 2 * self.loglikelihood
    )
    self.bic = (
        jnp.log(data_struct.num_cases) * len(self.coeff_) - 2 * self.loglikelihood
    )
    self.abic = (
        jnp.log((data_struct.num_cases + 2) / 24) * len(self.coeff_)
        - 2 * self.loglikelihood
    )

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

predict(data, alts_col, cases_col, panels_col=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.

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.

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,
    cases_col: str,
    panels_col: str | None = None,
) -> pl.DataFrame:
    """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.
    """
    parsed = self.model._transform_data(data)
    probs, _ = _choice_probabilities_and_logsum(
        parsed.X,
        self.coeff_[:, None],
        parsed.cases,
        int(jnp.max(parsed.cases)) + 1,
    )

    result_dict = {
        "cases": parsed.original_cases,
        "alts": parsed.original_alts,
        "choice_probs": onp.array(probs[:, 0], dtype=onp.float64),
    }

    if panels_col is not None:
        result_dict["panels"] = parsed.original_panels

    return pl.DataFrame(result_dict)

summarize(num_decimals=3)

Alias for :meth:summarize_betas.

Source code in src/lcl/conditional_logit.py
def summarize(self, num_decimals: int = 3) -> None:
    """Alias for :meth:`summarize_betas`."""
    self.summarize_betas(num_decimals=num_decimals)

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

Print LaTeX and plain-text tables summarizing parameter estimates and standard errors.

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,
) -> None:
    """Print LaTeX and plain-text tables summarizing parameter estimates and standard errors."""
    body_rows, data_clean = [], []
    converter = LatexNodes2Text(math_mode="text")
    header_clean = [converter.latex_to_text(col) for col in header]

    for coeff_idx, coeff_nm in enumerate(self.model.case_varnames):
        body_rows.append(
            f"{coeff_nm} & {self.coeff_[coeff_idx]:.{num_decimals}f} & {self.stderr[coeff_idx]:.{num_decimals}f} \\\\"
        )
        var_clean = converter.latex_to_text(coeff_nm)
        data_clean.append(
            (
                var_clean,
                f"{self.coeff_[coeff_idx]:.{num_decimals}f}",
                f"{self.stderr[coeff_idx]:.{num_decimals}f}",
            )
        )

    latex_string = "\n".join(
        [r"\toprule", " & ".join(header) + r" \\", r"\midrule", "%"]
        + body_rows
        + ["%", r"\bottomrule "]
    )
    table_preview = tabulate(
        data_clean,
        headers=header_clean,
        tablefmt="simple_outline",
        floatfmt=f".{num_decimals}f",
    )
    log_or_print(
        logger,
        "\n--- LaTeX Output ---\n\n%s\n\n--- Table preview ---\n\n%s",
        latex_string,
        table_preview,
    )