Skip to content

Latent-class conditional logit

The latent-class estimator fits a finite mixture of conditional logits by expectation-maximization. Class membership may be represented by aggregate shares or modeled as a function of panel characteristics with fractional multinomial logit.

Most users should start with LCLSpec and lcl.fit. The lower-level class remains available for direct use.

import lcl
from lcl import FitOptions, Options

options = Options(fit=FitOptions(starts=3))
results = lcl.fit(data, spec, options=options)

# Lower-level orchestration when a persistent model object is useful:
model = lcl.LatentClassConditionalLogit(spec=spec)
results = model.fit(data, options=options)

Pass spec by keyword to the lower-level constructor. Explicit num_classes and numeraire_min_abs override their base-specification values; omitted values inherit them. The historical direct-constructor default without a spec is five classes, while LCLSpec defaults to two. Create a new model for each fit.

Model

lcl.LatentClassConditionalLogit(num_classes=None, numeraire=None, *, spec=None, numeraire_min_abs=None)

Bases: ChoiceModel

Specification and estimation for latent-class conditional logit models.

This class provides the interface for defining and fitting a latent-class conditional logit model using an Expectation-Maximization (EM) algorithm. It inherits from the abstract base class ChoiceModel and manages the data ingestion, initialization, and iterative optimization of latent taste parameters and class membership probabilities.

Parameters:

Name Type Description Default
num_classes int | None

Number of latent classes. Omission uses spec.classes when supplied, otherwise the historical constructor default of five classes.

None
numeraire str | None

The name of the variable to be used as the numeraire (e.g., price or cost). If specified, its taste parameter is mathematically constrained to be at most -numeraire_min_abs across all classes, with the bound enforced directly during optimization.

None
spec LCLSpec | None

Base specification. Explicit constructor values override its class count and numeraire floor. A conflicting numeraire name raises an error.

None
numeraire_min_abs float | None

Positive coefficient floor. Omission uses the specification's floor, otherwise 1e-5.

None

Attributes:

Name Type Description
num_classes int

The number of discrete latent classes.

numeraire str | None

The name of the numeraire variable.

numeraire_idx int | None

The column index of the numeraire variable in the expanded design matrix, resolved during the fit method.

num_vars int

The total number of alternative-specific variables (taste parameters), resolved during the fit method.

num_dem_vars int

The total number of demographic variables, resolved during the fit method.

Create an unfitted latent-class conditional-logit model specification.

Source code in src/lcl/latent_class_conditional_logit.py
def __init__(
    self,
    num_classes: int | None = None,
    numeraire: str | None = None,
    *,
    spec: LCLSpec | None = None,
    numeraire_min_abs: float | None = None,
) -> None:
    """Create an unfitted latent-class conditional-logit model specification."""
    super().__init__()
    if spec is not None:
        if num_classes is None:
            num_classes = spec.classes
        if (
            numeraire is not None
            and spec.numeraire is not None
            and numeraire != spec.numeraire
        ):
            raise ValueError(
                "numeraire conflicts with the negative constraint in spec."
            )
        numeraire = numeraire or spec.numeraire
        if numeraire_min_abs is None:
            numeraire_min_abs = spec.numeraire_min_abs

    if num_classes is None:
        num_classes = 5
    _require_integer(num_classes, "num_classes")
    if num_classes < 2:
        raise ValueError("num_classes must be at least 2.")
    if numeraire_min_abs is None:
        numeraire_min_abs = DEFAULT_NEGATIVE_MIN_ABS
    NegativeCoefficient(min_abs=numeraire_min_abs)

    self.spec = spec
    self.num_classes = num_classes
    self.numeraire = numeraire
    self.numeraire_min_abs = numeraire_min_abs
    self.numeraire_idx: int | None = None

fit(data, alts_col=None, cases_col=None, panels_col=None, utility_formula=None, membership_formula=None, choice_col=None, case_varnames=None, dem_varnames=None, variable_labels=None, dems_data=None, options=None, fit_options=None, optimization_options=None, inference=None, diagnostics=None, progress_callback=None)

Fit the latent-class conditional logit model using an EM algorithm.

This method ingests raw data, translates it into strictly contiguous, zero-indexed JAX arrays (PyTrees), and executes the hardware-accelerated EM optimization routine.

Parameters:

Name Type Description Default
data Any

The main dataset containing choice situations. Accepts a Polars DataFrame, Pandas DataFrame, or dictionary of arrays.

required
alts_col str | None

The name of the column identifying specific alternatives within a choice situation.

None
cases_col str | None

The name of the column grouping observations into distinct choice situations.

None
panels_col str | None

The name of the column mapping choice situations to specific decision-makers (panels).

None
utility_formula str | None

Formulaic string for the alternative-specific utility specification. Examples include "choice ~ cost + time + C(mode)" or, when choice_col supplies the outcome, "~ cost + time + C(mode)".

None
membership_formula str | None

Right-hand-side Formulaic string for class-membership demographics, for example "~ income + C(segment)". A left-hand side is not accepted because latent class labels are unobserved.

None
choice_col str | None

The name of the boolean or binary column indicating chosen alternatives. Required when utility_formula has no left-hand side.

None
case_varnames Sequence[str] | None

A list of alternative-specific variables to include in the utility specification. Required if utility_formula is not provided.

None
dem_varnames Sequence[str] | None

A list of demographic variables used to predict latent class membership.

None
variable_labels Mapping[str, str] | None

Optional mapping from raw DataFrame/model variable names to human-readable labels used in presentation tables. Labels do not change model specification, constraints, prediction inputs, or WTP request names.

None
dems_data Any | None

An optional, separate panel-level dataset containing demographics. If provided, it will be merged with the main data on panels_col.

None
options Options | None

Complete fit configuration. Do not combine with individual option arguments.

None
fit_options FitOptions | None

Preferred EM settings, including multi-start orchestration.

None
optimization_options OptimizationOptions | None

Preferred exact-Newton M-step settings.

None
inference InferenceOptions | None

Preferred covariance and standard-error settings.

None
diagnostics DiagnosticsOptions | None

Diagnostic thresholds and switches.

None
progress_callback callable | None

Receives structured hardware, start, EM-step, and completion events.

None
Notes

Case or panel weights are not supported for latent-class estimation and this method takes no weights argument; passing one is a :class:TypeError rather than a silent no-op. Weighted estimation is available for :class:~lcl.conditional_logit.ConditionalLogit.

Returns:

Type Description
class:`~lcl._results.LCLResults`

A container holding the estimated parameters, optimization metadata, information criteria, and methods for inference (standard errors, predictions).

Raises:

Type Description
ValueError

If a numeraire was specified during class instantiation but cannot be found in the expanded design matrix columns.

Source code in src/lcl/latent_class_conditional_logit.py
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
def fit(
    self,
    data: Any,
    alts_col: str | None = None,
    cases_col: str | None = None,
    panels_col: str | None = None,
    utility_formula: str | None = None,
    membership_formula: str | None = None,
    choice_col: str | None = None,
    case_varnames: Sequence[str] | None = None,
    dem_varnames: Sequence[str] | None = None,
    variable_labels: Mapping[str, str] | None = None,
    dems_data: Any | None = None,
    options: Options | None = None,
    fit_options: FitOptions | None = None,
    optimization_options: OptimizationOptions | None = None,
    inference: InferenceOptions | None = None,
    diagnostics: DiagnosticsOptions | None = None,
    progress_callback: Callable[[dict[str, Any]], None] | None = None,
) -> LCLResults:
    """Fit the latent-class conditional logit model using an EM algorithm.

    This method ingests raw data, translates it into strictly contiguous,
    zero-indexed JAX arrays (PyTrees), and executes the hardware-accelerated
    EM optimization routine.

    Parameters
    ----------
    data : Any
        The main dataset containing choice situations. Accepts a Polars DataFrame,
        Pandas DataFrame, or dictionary of arrays.
    alts_col : str | None, optional
        The name of the column identifying specific alternatives within a choice
        situation.
    cases_col : str | None, optional
        The name of the column grouping observations into distinct choice
        situations.
    panels_col : str | None, optional
        The name of the column mapping choice situations to specific
        decision-makers (panels).
    utility_formula : str | None, default=None
        Formulaic string for the alternative-specific utility specification.
        Examples include ``"choice ~ cost + time + C(mode)"`` or, when
        ``choice_col`` supplies the outcome, ``"~ cost + time + C(mode)"``.
    membership_formula : str | None, default=None
        Right-hand-side Formulaic string for class-membership demographics,
        for example ``"~ income + C(segment)"``.  A left-hand side is not
        accepted because latent class labels are unobserved.
    choice_col : str | None, default=None
        The name of the boolean or binary column indicating chosen alternatives.
        Required when ``utility_formula`` has no left-hand side.
    case_varnames : Sequence[str] | None, default=None
        A list of alternative-specific variables to include in the utility
        specification. Required if ``utility_formula`` is not provided.
    dem_varnames : Sequence[str] | None, default=None
        A list of demographic variables used to predict latent class membership.
    variable_labels : Mapping[str, str] | None, default=None
        Optional mapping from raw DataFrame/model variable names to
        human-readable labels used in presentation tables.  Labels do not
        change model specification, constraints, prediction inputs, or WTP
        request names.
    dems_data : Any | None, default=None
        An optional, separate panel-level dataset containing demographics. If
        provided, it will be merged with the main `data` on `panels_col`.
    options : Options | None, optional
        Complete fit configuration. Do not combine with individual option arguments.
    fit_options : FitOptions | None, optional
        Preferred EM settings, including multi-start orchestration.
    optimization_options : OptimizationOptions | None, optional
        Preferred exact-Newton M-step settings.
    inference : InferenceOptions | None, optional
        Preferred covariance and standard-error settings.
    diagnostics : DiagnosticsOptions | None, optional
        Diagnostic thresholds and switches.
    progress_callback : callable | None, optional
        Receives structured hardware, start, EM-step, and completion events.

    Notes
    -----
    Case or panel weights are not supported for latent-class estimation and
    this method takes no ``weights`` argument; passing one is a
    :class:`TypeError` rather than a silent no-op.  Weighted estimation is
    available for :class:`~lcl.conditional_logit.ConditionalLogit`.

    Returns
    -------
    :class:`~lcl._results.LCLResults`
        A container holding the estimated parameters, optimization metadata,
        information criteria, and methods for inference (standard errors,
        predictions).

    Raises
    ------
    ValueError
        If a `numeraire` was specified during class instantiation but cannot be
        found in the expanded design matrix columns.
    """
    if self._encoder is not None:
        raise RuntimeError(
            "This model already has a fitted encoder. Create a new model "
            "instance for another fit."
        )
    self.spec = resolve_lcl_spec(
        spec=self.spec,
        alts_col=alts_col,
        cases_col=cases_col,
        panels_col=panels_col,
        choice_col=choice_col,
        case_varnames=case_varnames,
        dem_varnames=dem_varnames,
        utility_formula=utility_formula,
        membership_formula=membership_formula,
        classes=self.num_classes,
        numeraire=self.numeraire,
        numeraire_min_abs=(
            self.numeraire_min_abs if self.numeraire is not None else None
        ),
        variable_labels=variable_labels,
    )
    alts_col = self.spec.ids.alt
    cases_col = self.spec.ids.case
    panels_col = self.spec.ids.panel
    choice_col = self.spec.ids.choice
    utility_formula = self.spec.utility_formula
    membership_formula = self.spec.membership_formula
    case_varnames = self.spec.utility
    dem_varnames = self.spec.membership
    variable_labels = self.spec.variable_labels
    self.num_classes = self.spec.classes
    self.numeraire = self.spec.numeraire
    self.numeraire_min_abs = self.spec.numeraire_min_abs

    resolved_options = _resolve_options(
        options,
        fit_options=fit_options,
        optimization_options=optimization_options,
        inference=inference,
        diagnostics=diagnostics,
    )
    fit_options = resolved_options.fit
    optimization_options = resolved_options.optimization
    inference = resolved_options.inference
    diagnostics = resolved_options.diagnostics
    if not inference.skip and inference.covariance == "robust":
        raise ValueError(
            "Case-level robust covariance is not valid for an LCL likelihood. "
            "Use covariance='clustered' or 'unadjusted'."
        )

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

    self._pre_fit(
        parsed_data.case_varnames,
        parsed_data.dem_varnames,
        self.numeraire,
        variable_labels=variable_labels,
    )
    self.num_vars = len(self.case_varnames)
    self.num_dem_vars = len(self.dem_varnames) if self.dem_varnames else 0

    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

    data_struct, _, _ = self._setup_data(parsed_data)
    if data_struct.num_panels is None:
        raise ValueError("panels_col is required for latent-class models.")
    if self.num_classes > data_struct.num_panels:
        raise ValueError("num_classes cannot exceed the number of panels.")
    diff_unchosen_chosen = _diff_unchosen_chosen(data_struct)
    packing = ParamPacking(
        num_alt_vars=self.num_vars,
        num_classes=self.num_classes,
        num_dem_vars=self.num_dem_vars,
        numeraire_idx=self.numeraire_idx,
        numeraire_min_abs=self.numeraire_min_abs,
    )

    # Resolve a coarser clustering once, in encoded panel order, so every
    # start shares it and the results object never re-reads the raw frame.
    cluster_ids: Int[onp.ndarray, "panels"] | None = None
    num_clusters: int | None = None
    cluster_column = inference.cluster_column
    if cluster_column is not None and not inference.skip:
        cluster_ids, num_clusters = self._resolve_panel_cluster_ids(
            data, parsed_data, cluster_column, panels_col=panels_col
        )
        logger.info(
            "Clustering standard errors on %r: %s groups across %s panels.",
            cluster_column,
            num_clusters,
            data_struct.num_panels,
        )

    num_devices = fit_options.num_devices
    if num_devices > 1:
        if self.num_classes % num_devices == 0:
            message = f"Distributing {self.num_classes} classes across {num_devices} devices."
        else:
            message = f"Found {num_devices} devices; padding classes for balanced sharding."
    else:
        message = "Running beta updates on a single device."
    logger.info(message)
    if progress_callback is not None:
        progress_callback({"event": "hardware", "message": message})

    # Independent starts share one ingested dataset and one compiled EM step,
    # and the winner is kept outright rather than refit from its seed.
    best_run: _EMRun | None = None
    failures: list[str] = []
    for start_index in range(fit_options.starts):
        seed = fit_options.seed + start_index
        if progress_callback is not None:
            progress_callback(
                {
                    "event": "start",
                    "start": start_index + 1,
                    "starts": fit_options.starts,
                    "seed": seed,
                }
            )
        start_options = replace(fit_options, seed=seed)
        if fit_options.starts == 1:
            # A single start has nothing to fall back on, so let the original
            # exception and its traceback reach the caller unwrapped.
            run = self._run_em(
                diff_unchosen_chosen=diff_unchosen_chosen,
                data_struct=data_struct,
                fit_options=start_options,
                optimization_options=optimization_options,
                progress_callback=progress_callback,
            )
        else:
            try:
                run = self._run_em(
                    diff_unchosen_chosen=diff_unchosen_chosen,
                    data_struct=data_struct,
                    fit_options=start_options,
                    optimization_options=optimization_options,
                    progress_callback=progress_callback,
                )
            except Exception as exc:  # noqa: BLE001 - reported to the caller
                failures.append(f"seed {seed}: {exc}")
                logger.warning("LCL start with seed %s failed: %s", seed, exc)
                continue
        if best_run is None or run.loglik > best_run.loglik:
            best_run = run

    if best_run is None:
        detail = "; ".join(failures)
        raise RuntimeError(f"All {fit_options.starts} EM starts failed: {detail}")
    if fit_options.starts > 1:
        logger.info(
            "Selected EM start seed %s with log likelihood %.6f.",
            best_run.seed,
            best_run.loglik,
        )

    em_vars = best_run.em_vars
    em_history_rows = best_run.history
    em_recursion = best_run.recursions

    # EM's likelihood stopping rule need not imply stationarity. A final
    # observed-data solve improves the score before covariance estimation.
    if em_vars.betas is None or em_vars.shares is None:
        raise RuntimeError("The EM run returned an incomplete parameter state.")
    flat_params = packing.pack(em_vars.betas, em_vars.thetas, em_vars.shares)
    polish_report: PolishReport | None = None
    if fit_options.polish:
        if progress_callback is not None:
            progress_callback({"event": "polish", "iterations": None})
        polished, polish_report = polish_observed_data(
            flat_params,
            diff_unchosen_chosen,
            data_struct,
            packing,
            optimization_options=replace(
                optimization_options,
                maxiter=fit_options.polish_maxiter,
                newton_decrement_tol=POLISH_DECREMENT_TOL,
            ),
        )
        if polish_report.accepted:
            em_vars = em_vars_from_flat(
                polished, diff_unchosen_chosen, data_struct, packing
            )
        score_max = polish_report.score_after
        if progress_callback is not None:
            progress_callback(
                {
                    "event": "polish",
                    "iterations": polish_report.iterations,
                    "score_before": polish_report.score_before,
                    "score_after": polish_report.score_after,
                }
            )
    else:
        score_max = observed_score_max(
            flat_params, diff_unchosen_chosen, data_struct, packing
        )

    em_vars, class_permutation = _canonicalize_classes(em_vars)
    if class_permutation != tuple(range(self.num_classes)):
        # Changing the baseline membership class changes the coordinates of
        # its score. Check stationarity in the final reported coordinates.
        if em_vars.betas is None:
            raise RuntimeError(
                "Canonicalization returned an incomplete parameter state."
            )
        score_max = observed_score_max(
            packing.pack(em_vars.betas, em_vars.thetas, em_vars.shares),
            diff_unchosen_chosen,
            data_struct,
            packing,
        )

    # A fit has converged when the observed-data score has actually vanished.
    # Reporting convergence from a log-likelihood change instead lets a
    # slowly crawling EM claim an optimum it has not reached.
    converged = bool(score_max <= fit_options.score_tol)
    if not converged:
        logger.warning(
            "The maximum absolute observed-data score per panel is %.3e, above the "
            "tolerance %.3g, so the estimate is not a stationary point of the "
            "mixture likelihood. Standard errors assume it is. Consider "
            "raising max_em_iter or polish_maxiter.",
            score_max,
            fit_options.score_tol,
        )

    em_history_rows = _permute_em_history(em_history_rows, class_permutation)
    final_em_iter = max(em_recursion - 1, 0)
    optimization_history_rows = self._optimizer_snapshot(
        em_vars, diff_unchosen_chosen, data_struct, final_em_iter
    )

    estim_time_sec = time() - self._fit_start_time

    logger.info("Estimation time: %.3f seconds", estim_time_sec)
    result = LCLResults(
        model_spec=self,
        em_vars=em_vars,
        estimation_data=data_struct,
        em_recursion=em_recursion,
        converged=converged,
        inference=inference,
        diagnostics_config=diagnostics,
        estim_time_sec=estim_time_sec,
        em_history=em_history_rows,
        optimization_history=optimization_history_rows,
        observed_score_max=score_max,
        score_tol=fit_options.score_tol,
        em_criterion_met=best_run.criterion_met,
        polish_report=polish_report,
        cluster_ids=cluster_ids,
        num_clusters=num_clusters,
        param_packing=packing,
    )
    self.convergence = result.converged
    if progress_callback is not None:
        progress_callback(
            {"event": "complete", "estimation_time_seconds": estim_time_sec}
        )
    return result

Results

lcl.results.LCLResults(model_spec, em_vars, estimation_data, em_recursion, converged, inference, estim_time_sec, diagnostics_config=None, em_history=None, optimization_history=None, observed_score_max=float('nan'), score_tol=0.0001, em_criterion_met=False, polish_report=None, cluster_ids=None, num_clusters=None, param_packing=None)

Post-estimation results and inference container.

Computes robust sandwich covariance matrices (clustered at the decision-maker level) and handles the extraction of population-level moments via the Delta Method.

Attributes:

Name Type Description
cov_matrix Float64[Array, 'all_params all_params']

Covariance of the reported (structural) parameters, aligned row for row with :meth:parameter_names. Constrained coefficients appear on the scale printed by :meth:class_coefficients, so sqrt(diag(cov_matrix)) matches the published standard errors away from binding constraints. Clustered covariance uses the Stata maximum-likelihood multiplier :math:(G / (G - 1)). With boundary="conditional" or boundary="projected", numerically binding prices have zero rows and columns; their class-specific SEs are suppressed. Prediction, WTP, and membership inference then condition on those prices being fixed. Projected coefficient-summary SEs are reported by :meth:beta_summary separately; they cannot be recovered from this conditional matrix.

inference_status str

Scope of cov_matrix: "regular", "conditional_on_boundary", "unavailable", or "skipped". This is separate from the summary table's inference_status column.

boundary_parameter_indices tuple[int, ...]

Numerically binding coefficient indices, aligned with :meth:parameter_names. Valid boundaries do not veto a predictive fit.

boundary_kkt_violation float

Largest feasible structural ascent per panel at a binding upper bound. A value above score_tol makes the result nonconverged, including when covariance estimation was skipped.

boundary_summary_diagnostics dict

Summary inference method and, after projected :meth:beta_summary, active/strict/weak indices, nuisance-adjusted multiplier statistics, selection threshold, directional-SD variables, Gaussian draw count, seed, and simulated dimension, information diagnostics, elapsed time, and any conditional-fallback reason.

caic float

Consistent Akaike Information Criterion (Bozdogan, 1987).

bic float

Bayesian Information Criterion (Schwarz, 1978).

adjusted_bic float

Sample-size adjusted BIC (Sclove, 1987).

Build a latent-class results object and compute inference artifacts.

Parameters:

Name Type Description Default
model_spec Any

Fitted model specification. Kept broad to avoid a runtime circular import with :class:~lcl.latent_class_conditional_logit.LatentClassConditionalLogit.

required
em_vars :class:`~lcl._struct.EMVars`

Final EM state containing parameters, probabilities, and log likelihood.

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

Encoded estimation data.

required
em_recursion int

Number of EM recursions completed before termination.

required
converged bool

Whether the final observed-data score met score_tol after EM and optional polishing.

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

Covariance and standard-error configuration.

required
estim_time_sec float

Wall-clock estimation time in seconds.

required
diagnostics_config :class:`~lcl.options.DiagnosticsOptions` | None

Thresholds and switches for public diagnostics.

None
em_history list[dict[str, Any]] | None

EM log-likelihood and class-share history.

None
optimization_history list[dict[str, Any]] | None

Final class-level M-step diagnostics.

None
observed_score_max float

Largest absolute component of the observed-data score per panel at the reported estimate. Recomputed when covariance estimation runs.

float('nan')
score_tol float

Stationarity tolerance used for the converged flag, reused by :meth:diagnostics so the two can never disagree.

1e-4
em_criterion_met bool

Whether the Aitken EM stopping criterion was satisfied before the iteration cap.

False
polish_report :class:`~lcl._polish.PolishReport` | None

Outcome of the observed-data Newton polish.

None
cluster_ids ArrayLike | None

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

None
num_clusters int | None

Number of distinct clusters implied by cluster_ids.

None
param_packing :class:`~lcl._params.ParamPacking` | None

Reuse of the packing built during estimation.

None
Source code in src/lcl/_results.py
def __init__(
    self,
    model_spec: Any,
    em_vars: EMVars,
    estimation_data: Data,
    em_recursion: int,
    converged: bool,
    inference: InferenceOptions | None,
    estim_time_sec: float,
    diagnostics_config: DiagnosticsOptions | None = None,
    em_history: list[dict[str, Any]] | None = None,
    optimization_history: list[dict[str, Any]] | None = None,
    observed_score_max: float = float("nan"),
    score_tol: float = 1e-4,
    em_criterion_met: bool = False,
    polish_report: PolishReport | None = None,
    cluster_ids: Integer[ArrayLike, "panels"] | None = None,
    num_clusters: int | None = None,
    param_packing: ParamPacking | None = None,
) -> None:
    """Build a latent-class results object and compute inference artifacts.

    Parameters
    ----------
    model_spec : Any
        Fitted model specification. Kept broad to avoid a runtime circular import
        with :class:`~lcl.latent_class_conditional_logit.LatentClassConditionalLogit`.
    em_vars : :class:`~lcl._struct.EMVars`
        Final EM state containing parameters, probabilities, and log likelihood.
    estimation_data : :class:`~lcl._struct.Data`
        Encoded estimation data.
    em_recursion : int
        Number of EM recursions completed before termination.
    converged : bool
        Whether the final observed-data score met ``score_tol`` after EM
        and optional polishing.
    inference : :class:`~lcl.options.InferenceOptions` | None
        Covariance and standard-error configuration.
    estim_time_sec : float
        Wall-clock estimation time in seconds.
    diagnostics_config : :class:`~lcl.options.DiagnosticsOptions` | None
        Thresholds and switches for public diagnostics.
    em_history : list[dict[str, Any]] | None
        EM log-likelihood and class-share history.
    optimization_history : list[dict[str, Any]] | None
        Final class-level M-step diagnostics.
    observed_score_max : float, optional
        Largest absolute component of the observed-data score per panel at
        the reported estimate. Recomputed when covariance estimation runs.
    score_tol : float, default=1e-4
        Stationarity tolerance used for the ``converged`` flag, reused by
        :meth:`diagnostics` so the two can never disagree.
    em_criterion_met : bool, default=False
        Whether the Aitken EM stopping criterion was satisfied before the
        iteration cap.
    polish_report : :class:`~lcl._polish.PolishReport` | None, optional
        Outcome of the observed-data Newton polish.
    cluster_ids : ArrayLike | None, optional
        Zero-indexed cluster identifier per panel, for clustering coarser than
        the decision-maker.
    num_clusters : int | None, optional
        Number of distinct clusters implied by ``cluster_ids``.
    param_packing : :class:`~lcl._params.ParamPacking` | None, optional
        Reuse of the packing built during estimation.
    """
    self.model = model_spec
    self.em_res = em_vars
    self.data = estimation_data
    self.total_recursions = em_recursion
    self.converged = converged
    self.estim_time_sec = estim_time_sec
    self.inference = (
        replace(inference) if inference is not None else InferenceOptions()
    )
    self.diagnostics_config = (
        replace(diagnostics_config)
        if diagnostics_config is not None
        else DiagnosticsOptions()
    )
    self.em_history_ = _history_frame(em_history)
    self.optimization_history_ = _history_frame(optimization_history)
    if self.em_res.betas is None:
        raise ValueError("Structural betas are required to construct LCL results.")
    if self.em_res.shares is None:
        raise ValueError("Class shares are required to construct LCL results.")
    if self.data.num_panels is None:
        raise ValueError("Panel identifiers are required for LCL results.")

    self._param_packing = param_packing or ParamPacking(
        num_alt_vars=self.model.num_vars,
        num_classes=self.model.num_classes,
        num_dem_vars=self.model.num_dem_vars,
        numeraire_idx=self.model.numeraire_idx,
        numeraire_min_abs=self.model.numeraire_min_abs,
    )
    self.flat_params = self._pack_params()
    self.num_params = self._param_packing.num_params
    self.score_tol = float(score_tol)
    self.em_criterion_met = bool(em_criterion_met)
    self.polish_report = polish_report
    self._cluster_ids = (
        None if cluster_ids is None else jnp.asarray(cluster_ids, dtype=jnp.int32)
    )
    self._num_clusters = num_clusters
    # Populated by _compute_covariance; stays None when inference is skipped.
    self.information_diagnostics: InformationDiagnostics | None = None
    self.information_weak_directions: list[WeakInformationDirection] = []
    self.observed_score_max = float(observed_score_max)
    self.boundary_parameter_indices = tuple(
        int(i) for i in boundary_indices(self.em_res.betas, self._param_packing)
    )
    self.boundary_kkt_violation = 0.0
    self.inference_status = "skipped" if self.inference.skip else "regular"
    self._boundary_summary_inputs: BoundarySummaryInputs | None = None
    self._boundary_summary_cache: pl.DataFrame | None = None
    self.boundary_summary_diagnostics: BoundarySummaryDiagnostics = {
        "method": self.inference_status
    }
    if self.boundary_parameter_indices and (
        self.inference.skip or self.inference.boundary == "strict"
    ):
        cpu = cpu_device()
        with jax.default_device(cpu):
            boundary_data = device_put_array_leaves(self.data, cpu)
            score = mean_score(
                device_put_array_leaves(self.flat_params, cpu),
                _diff_unchosen_chosen(boundary_data),
                boundary_data,
                self._param_packing,
            )
        self.boundary_kkt_violation = boundary_kkt_violation(
            score, list(self.boundary_parameter_indices)
        )
        self.observed_score_max = float(
            jnp.max(
                jnp.abs(
                    projected_score(
                        score, self.flat_params, self._param_packing.upper_bounds()
                    )
                )
            )
        )
        self.converged = bool(self.observed_score_max <= self.score_tol)
        if not self.converged:
            logger.warning(
                "Boundary KKT violation per panel: %.3e (tolerance %.3e).",
                self.boundary_kkt_violation,
                self.score_tol,
            )
    self.cov_matrix = self._compute_covariance()
    if not self.inference.skip and not self.covariance_available:
        self.inference_status = "unavailable"
    self.boundary_summary_diagnostics["method"] = self.inference_status

    # Compute information criteria
    num_panels = self.data.num_panels
    self.aic = 2 * self.num_params - 2 * self.em_res.unconditional_loglik
    self.aic3 = 3 * self.num_params - 2 * self.em_res.unconditional_loglik
    self.caic = (
        jnp.log(num_panels) + 1
    ) * self.num_params - 2 * self.em_res.unconditional_loglik
    self.bic = (
        jnp.log(num_panels) * self.num_params - 2 * self.em_res.unconditional_loglik
    )
    self.adjusted_bic = (
        jnp.log((num_panels + 2) / 24) * self.num_params
        - 2 * self.em_res.unconditional_loglik
    )
    logger.info(
        "Information criteria: CAIC=%.1f, BIC=%.1f, adjusted BIC=%.1f",
        self.caic,
        self.bic,
        self.adjusted_bic,
    )

    if not self.converged:
        logger.warning(
            "Optimization did not converge after %s iterations.",
            self.total_recursions,
        )

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 a finite covariance; inspect inference_status for conditional scope.

__repr__()

Return a compact, human-readable summary of fit quality.

Source code in src/lcl/_results.py
def __repr__(self) -> str:
    """Return a compact, human-readable summary of fit quality."""
    status = "Converged" if self.converged else "Did Not Converge"
    parts = [
        f"<LCLResults: {self.model.num_classes} Classes",
        f"{status}",
        f"Log likelihood: {self.em_res.unconditional_loglik:.1f}",
        f"CAIC: {self.caic:.1f}",
        f"BIC: {self.bic:.1f}",
        f"Adj. BIC: {self.adjusted_bic:.1f}",
    ]
    if not self.inference.skip and not self.covariance_available:
        parts.append("Covariance unavailable")
    return " | ".join(parts) + ">"

audit_report()

Return a text audit report for replication materials.

Source code in src/lcl/_results.py
def audit_report(self) -> str:
    """Return a text audit report for replication materials."""
    diagnostics_table = self.diagnostics().to_frame()
    return "\n\n".join(
        [
            "1. Model Specification\n" + self.spec_summary(),
            "2. Fit Statistics\n"
            + "\n".join(
                [
                    f"Log likelihood: {float(self.em_res.unconditional_loglik):.6g}",
                    f"CAIC: {float(self.caic):.6g}",
                    f"BIC: {float(self.bic):.6g}",
                    f"Adjusted BIC: {float(self.adjusted_bic):.6g}",
                    f"Estimation seconds: {self.estim_time_sec:.3f}",
                ]
            ),
            "3. Class Shares\n" + str(self.class_shares()),
            "4. Diagnostics\n" + str(diagnostics_table),
        ]
    )

beta_summary()

Return population coefficient moments and explicitly labelled uncertainty.

With boundary="projected", nearby price constraints use Gaussian critical-cone simulation; otherwise the ordinary/conditional delta method applies. These SEs do not imply normal confidence intervals. See docs/boundary_inference.md.

Returns:

Type Description
DataFrame

Raw variables, display labels, mean coefficients, standard deviations across classes, their standard errors, class-specific extrema, and an inference_status column. Projected rows use a Gaussian critical-cone approximation, with an explicitly labelled conditional fallback when that approximation is unavailable.

Source code in src/lcl/_results.py
def beta_summary(self) -> pl.DataFrame:
    """Return population coefficient moments and explicitly labelled uncertainty.

    With boundary="projected", nearby price constraints use Gaussian
    critical-cone simulation; otherwise the ordinary/conditional delta
    method applies. These SEs do not imply normal confidence intervals.
    See docs/boundary_inference.md.

    Returns
    -------
    pl.DataFrame
        Raw variables, display labels, mean coefficients, standard deviations
        across classes, their standard errors, class-specific extrema, and
        an ``inference_status`` column. Projected rows use a Gaussian
        critical-cone approximation, with an explicitly labelled
        conditional fallback when that approximation is unavailable.
    """
    if self.data.num_panels is None:
        raise ValueError("Panel identifiers are required to summarize LCL results.")
    if getattr(self, "_boundary_summary_inputs", None) is not None:
        from lcl._boundary_inference import projected_beta_summary

        return projected_beta_summary(self)

    means, se_means = self._apply_delta_method(
        self._calc_population_mean_betas,
        self.flat_params,
        dems=self.data.dems,
        num_panels=self.data.num_panels,
    )
    variances, se_variances = self._apply_delta_method(
        self._calc_population_var_betas,
        self.flat_params,
        dems=self.data.dems,
        num_panels=self.data.num_panels,
    )
    structural = onp.asarray(self.em_res.betas)

    # sd = sqrt(var), so se(sd) = se(var) / (2 sd) -- but only where the
    # variance is separated from zero.  A variable whose coefficient is
    # common to every class has no identified spread, and the derivative of
    # the square root there is unbounded, so the standard error is NaN rather
    # than the zero a floored square root would silently report.
    variance_array = onp.asarray(variances, dtype=onp.float64)
    se_variance_array = onp.asarray(se_variances, dtype=onp.float64)
    scale = onp.maximum(onp.max(structural**2, axis=1), 1.0)
    identified = variance_array > DEGENERATE_VARIANCE_RTOL * scale
    stds = onp.sqrt(onp.maximum(variance_array, 0.0))
    with onp.errstate(divide="ignore", invalid="ignore"):
        se_stds = onp.where(identified, se_variance_array / (2.0 * stds), onp.nan)
    if not bool(onp.all(identified)):
        degenerate = [
            variable
            for variable, keep in zip(self.model.case_varnames, identified)
            if not keep
        ]
        logger.warning(
            "Between-class spread is not identified for %s: the coefficient "
            "is common to every class, so its standard deviation has no "
            "standard error.",
            ", ".join(degenerate),
        )
    rows = []
    for idx, variable in enumerate(self.model.case_varnames):
        rows.append(
            {
                "variable": variable,
                "label": _model_variable_label(self.model, variable),
                "mean": float(means[idx]),
                "mean_se": (
                    float("nan")
                    if all(
                        idx * self.model.num_classes + cls
                        in getattr(self, "boundary_parameter_indices", ())
                        for cls in range(self.model.num_classes)
                    )
                    else float(se_means[idx])
                ),
                "inference_status": getattr(self, "inference_status", "regular"),
                "sd": float(stds[idx]),
                "sd_se": float(se_stds[idx]),
                "min_class": float(onp.min(structural[idx, :])),
                "max_class": float(onp.max(structural[idx, :])),
            }
        )
    return pl.DataFrame(rows)

class_coefficients()

Return class-specific structural coefficients.

Returns:

Type Description
DataFrame

Long-format table with one row per variable and latent class. The variable column preserves raw model names; label contains human-readable presentation labels.

Source code in src/lcl/_results.py
def class_coefficients(self) -> pl.DataFrame:
    """Return class-specific structural coefficients.

    Returns
    -------
    pl.DataFrame
        Long-format table with one row per variable and latent class.  The
        ``variable`` column preserves raw model names; ``label`` contains
        human-readable presentation labels.
    """
    betas, _ = self._unpack_params(self.flat_params)
    standard_errors, _ = self._unpack_params(jnp.sqrt(jnp.diag(self.cov_matrix)))
    rows = []
    beta_array = onp.asarray(betas)
    se_array = onp.asarray(standard_errors)
    for var_idx, variable in enumerate(self.model.case_varnames):
        for class_idx in range(self.model.num_classes):
            rows.append(
                {
                    "variable": variable,
                    "label": _model_variable_label(self.model, variable),
                    "class": class_idx,
                    "coefficient": float(beta_array[var_idx, class_idx]),
                    "std_error": (
                        float("nan")
                        if var_idx * self.model.num_classes + class_idx
                        in getattr(self, "boundary_parameter_indices", ())
                        else float(se_array[var_idx, class_idx])
                    ),
                    "boundary": var_idx * self.model.num_classes + class_idx
                    in getattr(self, "boundary_parameter_indices", ()),
                    "inference_status": getattr(
                        self, "inference_status", "regular"
                    ),
                    "constrained": variable == self.model.numeraire,
                }
            )
    return pl.DataFrame(rows)

class_shares()

Return aggregate latent-class shares.

Returns:

Type Description
DataFrame

One row per latent class with aggregate class share and effective panel mass.

Source code in src/lcl/_results.py
def class_shares(self) -> pl.DataFrame:
    """Return aggregate latent-class shares.

    Returns
    -------
    pl.DataFrame
        One row per latent class with aggregate class share and effective
        panel mass.
    """
    if self.data.num_panels is None:
        raise ValueError("Panel identifiers are required for class shares.")
    shares, share_se = self._apply_delta_method(
        self._calc_class_shares,
        self.flat_params,
        dems=self.data.dems,
        num_panels=self.data.num_panels,
    )
    shares_array = onp.asarray(shares)
    share_se_array = onp.asarray(share_se)
    rows = []
    posterior = self.em_res.class_probs_by_panel
    posterior_arr = onp.asarray(posterior) if posterior is not None else None
    for class_idx, share in enumerate(shares_array):
        row = {
            "class": class_idx,
            "share": float(share),
            "std_error": float(share_se_array[class_idx]),
        }
        if posterior_arr is not None:
            row["effective_panels"] = float(posterior_arr[:, class_idx].sum())
        rows.append(row)
    return pl.DataFrame(rows)

classification_diagnostics()

Summarize posterior separation and modal classification by class.

Source code in src/lcl/_results.py
def classification_diagnostics(self) -> pl.DataFrame:
    """Summarize posterior separation and modal classification by class."""
    posterior = self.em_res.class_probs_by_panel
    if posterior is None:
        raise ValueError("Posterior class probabilities are required.")
    probabilities = onp.asarray(posterior, dtype=onp.float64)
    modal = onp.argmax(probabilities, axis=1)
    entropy = -onp.sum(probabilities * onp.log(onp.maximum(probabilities, 1e-300)))
    entropy_r2 = 1.0 - entropy / (
        probabilities.shape[0] * onp.log(self.model.num_classes)
    )
    prior_shares = probabilities.mean(axis=0)
    rows = []
    for class_idx in range(self.model.num_classes):
        selected = modal == class_idx
        modal_count = int(selected.sum())
        average_posterior = (
            float(probabilities[selected, class_idx].mean())
            if modal_count
            else float("nan")
        )
        prior = float(prior_shares[class_idx])
        if modal_count and 0.0 < average_posterior < 1.0 and 0.0 < prior < 1.0:
            occ = (average_posterior / (1.0 - average_posterior)) / (
                prior / (1.0 - prior)
            )
        else:
            occ = float("nan")
        rows.append(
            {
                "class": class_idx,
                "modal_panels": modal_count,
                "modal_share": modal_count / probabilities.shape[0],
                "average_posterior": average_posterior,
                "odds_correct_classification": occ,
                "entropy_r2": float(entropy_r2),
            }
        )
    return pl.DataFrame(rows)

convergence_report()

Return a compact convergence and diagnostic report.

Source code in src/lcl/_results.py
def convergence_report(self) -> str:
    """Return a compact convergence and diagnostic report."""
    diagnostics = self.diagnostics().to_frame()
    warnings = diagnostics.filter(pl.col("status") != "ok")
    lines = [
        f"Converged: {self.converged}",
        f"EM recursions: {self.total_recursions}",
        f"EM criterion met: {self.em_criterion_met}",
        f"Final log likelihood: {float(self.em_res.unconditional_loglik):.6g}",
        f"Max observed-data score: {self.observed_score_max:.3e} "
        f"(tolerance {self.score_tol:.3g})",
        f"Boundary KKT violation: {getattr(self, 'boundary_kkt_violation', 0.0):.3e}",
        f"Inference: {getattr(self, 'inference_status', 'regular')}",
        f"Warnings: {warnings.height}",
    ]
    if self.polish_report is not None:
        report = self.polish_report
        lines.append(
            f"Observed-data polish: {report.iterations} Newton step(s), "
            f"log likelihood {report.loglik_before:.6f} -> "
            f"{report.loglik_after:.6f}, score {report.score_before:.3e} -> "
            f"{report.score_after:.3e}"
        )
    if self.em_history_.height:
        last = self.em_history_.tail(1).row(0, named=True)
        lines.append(f"Last EM history row: {last}")
    return "\n".join(lines)

diagnose()

Alias for :meth:diagnostics.

Source code in src/lcl/_results.py
def diagnose(self) -> LCLDiagnostics:
    """Alias for :meth:`diagnostics`."""
    return self.diagnostics()

diagnostics()

Return structured model diagnostics.

Source code in src/lcl/_results.py
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
def diagnostics(self) -> LCLDiagnostics:
    """Return structured model diagnostics."""
    rows: list[dict[str, object]] = [
        {
            "section": "fit",
            "check": "converged",
            "value": bool(self.converged),
            "status": "ok" if self.converged else "warning",
            "message": (
                "Whether the estimate is a stationary point of the mixture "
                "likelihood, judged by observed_score_max against score_tol."
            ),
        },
        {
            "section": "fit",
            "check": "log_likelihood",
            "value": float(self.em_res.unconditional_loglik),
            "status": "ok",
            "message": "Final unconditional log likelihood.",
        },
        {
            "section": "fit",
            "check": "observed_score_max",
            "value": self.observed_score_max,
            "status": (
                "ok"
                if onp.isfinite(self.observed_score_max)
                and self.observed_score_max <= self.score_tol
                else "warning"
            ),
            "message": (
                "Maximum absolute component of the final observed-data score per panel, "
                f"against the score_tol of {self.score_tol:.3g}. This is the "
                "same test that sets the converged flag, so the two agree by "
                "construction."
            ),
        },
        {
            "section": "fit",
            "check": "em_criterion_met",
            "value": bool(self.em_criterion_met),
            "status": "ok" if self.em_criterion_met else "warning",
            "message": (
                "Whether the Aitken-extrapolated EM criterion was met before "
                "the iteration cap."
            ),
        },
        {
            "section": "data",
            "check": "panels",
            "value": int(self.data.num_panels or 0),
            "status": "ok",
            "message": "Number of decision-maker panels.",
        },
        {
            "section": "data",
            "check": "cases",
            "value": int(self.data.num_cases),
            "status": "ok",
            "message": "Number of choice situations.",
        },
    ]

    rows.extend(
        [
            {
                "section": "fit",
                "check": "boundary_kkt_violation",
                "value": getattr(self, "boundary_kkt_violation", 0.0),
                "status": "ok"
                if getattr(self, "boundary_kkt_violation", 0.0) <= self.score_tol
                else "warning",
                "message": "Feasible structural ascent at binding negative coefficients; valid boundaries satisfy KKT.",
            },
            {
                "section": "inference",
                "check": "boundary_inference",
                "value": getattr(self, "inference_status", "regular"),
                "status": "warning"
                if getattr(self, "boundary_parameter_indices", ())
                else "ok",
                "message": "Conditional covariance holds binding prices fixed; it is not full boundary uncertainty.",
            },
        ]
    )
    if self.polish_report is not None:
        report = self.polish_report
        rows.append(
            {
                "section": "fit",
                "check": "polish_loglik_gain",
                "value": float(report.loglik_after - report.loglik_before),
                "status": "ok",
                "message": (
                    f"Log likelihood gained by {report.iterations} "
                    "observed-data Newton step(s) after EM. A large value "
                    "means EM stopped well short of the optimum."
                ),
            }
        )
        rows.append(
            {
                "section": "fit",
                "check": "polish_score_reduction",
                "value": float(report.score_before),
                "status": "ok",
                "message": (
                    "Observed-data score before the polish, for comparison "
                    "with observed_score_max after it."
                ),
            }
        )

    rows.append(
        {
            "section": "inference",
            "check": "covariance_available",
            "value": bool(self.inference.skip or self.covariance_available),
            "status": (
                "ok"
                if self.inference.skip or self.covariance_available
                else "warning"
            ),
            "message": (
                "Whether a finite covariance matrix was estimated. When false, "
                "every standard error in this results object is NaN."
            ),
        }
    )

    if self.diagnostics_config.check_separation and self.data.num_panels:
        prior = onp.asarray(
            self._get_class_probs(
                self._unpack_params(self.flat_params)[1],
                self.data.dems,
                self.data.num_panels,
            )
        )
        min_prior = float(prior.min())
        separated = min_prior <= self.diagnostics_config.separation_threshold
        rows.append(
            {
                "section": "latent_class",
                "check": "min_membership_probability",
                "value": min_prior,
                "status": "warning" if separated else "ok",
                "message": (
                    "Smallest prior class-membership probability over panels. "
                    "A value at zero means some demographic cell never belongs "
                    "to that class, so its membership coefficients are "
                    "unbounded and the observed information is singular in "
                    "that direction. Merge the cell, drop the variable, or fit "
                    "fewer classes."
                ),
            }
        )

    if self.information_diagnostics is not None and (
        self.diagnostics_config.check_collinearity
    ):
        info = self.information_diagnostics
        rows.append(
            {
                "section": "inference",
                "check": "information_rank",
                "value": float(info.rank),
                "status": "warning" if info.rank_deficient else "ok",
                "message": (
                    f"Numerical rank of the observed information out of "
                    f"{info.num_params} parameters. A deficient rank means "
                    "some standard errors are not identified."
                ),
            }
        )
        rows.append(
            {
                "section": "inference",
                "check": "information_condition_number",
                "value": float(info.condition_number),
                "status": (
                    "warning"
                    if not info.positive_definite or info.condition_number > 1e12
                    else "ok"
                ),
                "message": (
                    "Ratio of largest to smallest eigenvalue of the observed "
                    "information. Large values indicate weakly identified "
                    "parameter directions."
                ),
            }
        )
        rows.append(
            {
                "section": "inference",
                "check": "information_min_eigenvalue",
                "value": float(info.smallest_eigenvalue),
                "status": "ok" if info.positive_definite else "warning",
                "message": (
                    "Smallest eigenvalue of the observed information. Values "
                    "at or below zero indicate a saddle point rather than a "
                    "maximum."
                ),
            }
        )

    for index, direction in enumerate(
        getattr(self, "information_weak_directions", [])
    ):
        rows.append(
            {
                "section": "inference",
                "check": f"weak_parameter_direction_{index + 1}",
                "value": direction["normalized_eigenvalue"],
                "status": "warning",
                "message": "Inspect this scaled parameter combination: "
                + str(direction["loadings"])
                + ". Check redundant attributes, sparse classes, or separated membership; "
                "more iterations cannot restore missing identification.",
            }
        )
    if self.em_res.class_probs_by_panel is not None:
        posterior = onp.asarray(self.em_res.class_probs_by_panel)
        entropy = -onp.sum(
            posterior * onp.log(onp.maximum(posterior, 1e-300)), axis=1
        )
        rows.append(
            {
                "section": "latent_class",
                "check": "posterior_entropy_mean",
                "value": float(entropy.mean()),
                "status": "ok",
                "message": "Mean entropy of posterior class membership.",
            }
        )

    shares_df = self.class_shares()
    min_share = float(cast(float, shares_df["share"].min()))
    rows.append(
        {
            "section": "latent_class",
            "check": "min_class_share",
            "value": min_share,
            "status": "warning" if min_share < 0.01 else "ok",
            "message": "Small classes can indicate weakly identified local optima.",
        }
    )
    if "effective_panels" in shares_df.columns:
        rows.append(
            {
                "section": "latent_class",
                "check": "min_effective_panels",
                "value": float(cast(float, shares_df["effective_panels"].min())),
                "status": "ok",
                "message": "Smallest posterior panel mass across classes.",
            }
        )

    structural = onp.asarray(self.em_res.betas)
    max_abs_beta = float(onp.max(onp.abs(structural)))
    rows.append(
        {
            "section": "coefficients",
            "check": "max_abs_beta",
            "value": max_abs_beta,
            "status": (
                "warning"
                if (
                    self.diagnostics_config.warn_large_coefficients
                    and max_abs_beta
                    > self.diagnostics_config.large_coefficient_threshold
                )
                else "ok"
            ),
            "message": "Largest absolute structural coefficient.",
        }
    )
    numeraire_idx = getattr(self.model, "numeraire_idx", None)
    if numeraire_idx is not None:
        min_abs_numeraire = float(onp.min(onp.abs(structural[numeraire_idx, :])))
        threshold = self.diagnostics_config.near_zero_numeraire_threshold
        spec = getattr(self.model, "spec", None)
        constraint = None if spec is None else spec.negative_constraint
        if constraint is not None and constraint.warn_below is not None:
            threshold = constraint.warn_below
        rows.append(
            {
                "section": "coefficients",
                "check": "min_abs_numeraire",
                "value": min_abs_numeraire,
                "status": (
                    "warning"
                    if (
                        self.diagnostics_config.warn_near_zero_numeraire
                        and min_abs_numeraire < threshold
                    )
                    else "ok"
                ),
                "message": "Small numeraires can dominate WTP/tradeoff ratios.",
            }
        )
        linear = numeraire_enters_linearly(self.model)
        rows.append(
            {
                "section": "coefficients",
                "check": "numeraire_enters_linearly",
                "value": float(linear),
                "status": "ok" if linear else "warning",
                "message": (
                    "Whether the numeraire enters utility as a bare column. "
                    "Consumer surplus and willingness to pay divide by "
                    "-dV/d(numeraire), which equals -beta only when the "
                    "numeraire is untransformed. Under a transform such as "
                    "log(price) the ratio is per unit of the transform, not "
                    "per dollar, and the constant-marginal-utility-of-income "
                    "assumption behind the log-sum welfare formula no longer "
                    "holds."
                ),
            }
        )

    return LCLDiagnostics(pl.DataFrame(rows))

loglik(data, dems_data=None, *, per_panel=False)

Score observed choices with the fitted empirical specification.

The fitted encoder is reused, so Formulaic categorical levels and expanded columns retain their training-time meaning.

Parameters:

Name Type Description Default
data object

Long-format data containing one observed choice per case.

required
dems_data object | None

Optional panel-level demographics joined by the fitted panel ID column.

None
per_panel bool

Return a panel-level table instead of the total log likelihood.

False

Returns:

Type Description
float or DataFrame

Total log likelihood when per_panel=False. Otherwise, a table with the original panel IDs and their log-likelihood contributions.

Source code in src/lcl/_results.py
def loglik(
    self,
    data: object,
    dems_data: object | None = None,
    *,
    per_panel: bool = False,
) -> float | pl.DataFrame:
    """Score observed choices with the fitted empirical specification.

    The fitted encoder is reused, so Formulaic categorical levels and expanded
    columns retain their training-time meaning.

    Parameters
    ----------
    data : object
        Long-format data containing one observed choice per case.
    dems_data : object | None, optional
        Optional panel-level demographics joined by the fitted panel ID column.
    per_panel : bool, default=False
        Return a panel-level table instead of the total log likelihood.

    Returns
    -------
    float or pl.DataFrame
        Total log likelihood when ``per_panel=False``. Otherwise, a table with
        the original panel IDs and their log-likelihood contributions.
    """
    parsed = self.model._transform_data(
        data,
        dems_data=dems_data,
        require_choice=True,
    )
    data_struct = cast(Data, self.model._setup_data(parsed)[0])
    if data_struct.num_panels is None or data_struct.panels is None:
        raise ValueError("Panel identifiers are required to score LCL data.")
    diff = _diff_unchosen_chosen(data_struct)
    panel_values = self._panel_loglik_fn(self.flat_params, diff, data_struct)

    if not per_panel:
        return float(jnp.sum(panel_values))

    first_panel_rows = data_struct.panels != jnp.roll(data_struct.panels, shift=1)
    first_panel_rows = first_panel_rows.at[0].set(True)
    return pl.DataFrame(
        {
            "panel": onp.asarray(parsed.original_panels[first_panel_rows]),
            "log_likelihood": onp.asarray(panel_values, dtype=onp.float64),
        }
    )

membership_coefficients()

Return nonbaseline class-membership coefficients with standard errors.

Class 0 is the reference category and therefore has no separately estimated membership coefficients.

Source code in src/lcl/_results.py
def membership_coefficients(self) -> pl.DataFrame:
    """Return nonbaseline class-membership coefficients with standard errors.

    Class 0 is the reference category and therefore has no separately
    estimated membership coefficients.
    """
    _, coefficients = self._unpack_params(self.flat_params)
    _, standard_errors = self._unpack_params(jnp.sqrt(jnp.diag(self.cov_matrix)))
    coefficient_array = onp.asarray(coefficients)
    se_array = onp.asarray(standard_errors)
    variables = ["Intercept", *(self.model.dem_varnames or [])]
    rows = []
    for variable_idx, variable in enumerate(variables):
        for class_idx in range(1, self.model.num_classes):
            rows.append(
                {
                    "variable": variable,
                    "label": _model_variable_label(self.model, variable),
                    "class": class_idx,
                    "reference_class": 0,
                    "coefficient": float(
                        coefficient_array[variable_idx, class_idx - 1]
                    ),
                    "std_error": float(se_array[variable_idx, class_idx - 1]),
                }
            )
    return pl.DataFrame(rows)

membership_marginal_effects()

Return average marginal effects of demographics on class membership.

The membership coefficients are log-odds against an arbitrary reference class, which makes them awkward to read and impossible to compare across fits that landed on a different reference. The average marginal effect (1/N) sum_n d P(class = s | z_n) / d z_nm is free of that normalisation, is denominated in probability points, and sums to zero across classes for each demographic variable.

Returns:

Type Description
DataFrame

One row per (demographic variable, class) with the average marginal effect and its Delta-method standard error.

Raises:

Type Description
ValueError

If the model was fitted without a demographic membership regression.

Notes

For a binary demographic this is the average of a derivative, not a discrete difference in probabilities; the two agree only approximately.

Source code in src/lcl/_results.py
def membership_marginal_effects(self) -> pl.DataFrame:
    """Return average marginal effects of demographics on class membership.

    The membership coefficients are log-odds against an arbitrary reference
    class, which makes them awkward to read and impossible to compare across
    fits that landed on a different reference.  The average marginal effect
    ``(1/N) sum_n d P(class = s | z_n) / d z_nm`` is free of that
    normalisation, is denominated in probability points, and sums to zero
    across classes for each demographic variable.

    Returns
    -------
    pl.DataFrame
        One row per (demographic variable, class) with the average marginal
        effect and its Delta-method standard error.

    Raises
    ------
    ValueError
        If the model was fitted without a demographic membership regression.

    Notes
    -----
    For a binary demographic this is the average of a derivative, not a
    discrete difference in probabilities; the two agree only approximately.
    """
    if self.em_res.thetas is None:
        raise ValueError(
            "This model has no class-membership regression, so demographics "
            "have no marginal effect on class membership."
        )
    if self.data.num_panels is None:
        raise ValueError("Panel identifiers are required for marginal effects.")
    effects, standard_errors = self._apply_delta_method(
        self._calc_membership_marginal_effects,
        self.flat_params,
        dems=self.data.dems,
        num_panels=self.data.num_panels,
    )
    effect_array = onp.asarray(effects)
    se_array = onp.asarray(standard_errors)
    rows = []
    for variable_idx, variable in enumerate(self.model.dem_varnames or []):
        for class_idx in range(self.model.num_classes):
            rows.append(
                {
                    "variable": variable,
                    "label": _model_variable_label(self.model, variable),
                    "class": class_idx,
                    "marginal_effect": float(effect_array[variable_idx, class_idx]),
                    "std_error": float(se_array[variable_idx, class_idx]),
                }
            )
    return pl.DataFrame(rows)

parameter_names()

Return names aligned with rows and columns of cov_matrix.

Source code in src/lcl/_results.py
def parameter_names(self) -> list[str]:
    """Return names aligned with rows and columns of ``cov_matrix``."""
    names = [
        f"class_{class_idx}:{variable}"
        for variable in self.model.case_varnames
        for class_idx in range(self.model.num_classes)
    ]
    membership_rows = ["Intercept", *(self.model.dem_varnames or [])]
    names.extend(
        f"membership_class_{class_idx}:{variable}"
        for variable in membership_rows
        for class_idx in range(1, self.model.num_classes)
    )
    if len(names) != self.num_params:
        raise RuntimeError(
            "Parameter-name layout does not match covariance packing."
        )
    return names

predict(data=None, *, X=None, alts=None, cases=None, panels=None, dems=None, dem_panel_ids=None, past_choices=None, dems_data=None, past_choices_dems_data=None, panel_weights=None)

Generate out-of-sample latent-class predictions.

Prediction can be requested either with raw tabular data, which is encoded using the fitted model specification, or with already-constructed arrays. When historical choices are supplied through past_choices, class membership probabilities are updated with Bayes' rule before computing counterfactual choice probabilities, consumer surplus, and willingness to pay.

Parameters:

Name Type Description Default
X ArrayLike | None

(rows, alt_vars) design matrix in fitted expanded-column order. Cannot be combined with data.

None
alts ArrayLike | None

Alternative identifiers aligned to rows of X.

None
cases ArrayLike | None

Choice-situation identifiers aligned to rows of X.

None
panels ArrayLike | None

Decision-maker identifiers aligned to rows of X.

None
dems ArrayLike | None

Panel-level demographics for array-style prediction. When dem_panel_ids is omitted, rows must be in sorted unique panel-ID order.

None
dem_panel_ids ArrayLike | None

Panel IDs aligned with rows of dems. The parser uses these IDs to validate and reorder demographic rows.

None
past_choices PastChoicesData or tabular data

Historical choices used to condition latent-class membership probabilities. Pass a :class:~lcl.options.PastChoicesData instance for array-style inputs, or a Polars/Pandas/DataFrame-like object containing the fitted model's identifiers, choices, and historical utility inputs. A subset of prediction panels is allowed; missing histories retain their prior. Membership priors always use prediction demographics. History only needs demographic columns when they enter historical utility.

None
data object | None

Long-format prediction data. If provided, the fitted encoder parses this data using the original empirical specification.

None
dems_data object | None

Optional panel-level demographics to merge into data during prediction.

None
past_choices_dems_data object | None

Optional panel-level demographics to merge into tabular past_choices. Cannot be used with :class:~lcl.options.PastChoicesData.

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. Individual probabilities do not depend on these weights.

None

Returns:

Type Description
class:`~lcl._prediction.LCLPrediction`

Prediction results, including choice probabilities, consumer surplus, panel-level WTP values, and the class probabilities used for prediction.

Raises:

Type Description
ValueError

If required prediction identifiers are missing, if fitted latent-class parameters are unavailable, or if past_choices_dems_data is provided without tabular past_choices.

Source code in src/lcl/_results.py
def predict(
    self,
    data: object | None = None,
    *,
    X: DesignInput | None = None,
    alts: RowIdsInput | None = None,
    cases: RowIdsInput | None = None,
    panels: RowIdsInput | None = None,
    dems: DemographicsInput | None = None,
    dem_panel_ids: PanelIdsInput | None = None,
    past_choices: object | None = None,
    dems_data: object | None = None,
    past_choices_dems_data: object | None = None,
    panel_weights: str | Mapping[object, float] | PanelWeightsInput | None = None,
) -> LCLPrediction:
    """Generate out-of-sample latent-class predictions.

    Prediction can be requested either with raw tabular data, which is encoded
    using the fitted model specification, or with already-constructed arrays.
    When historical choices are supplied through ``past_choices``, class
    membership probabilities are updated with Bayes' rule before computing
    counterfactual choice probabilities, consumer surplus, and willingness to pay.

    Parameters
    ----------
    X : ArrayLike | None, optional
        ``(rows, alt_vars)`` design matrix in fitted expanded-column order.
        Cannot be combined with ``data``.
    alts : ArrayLike | None, optional
        Alternative identifiers aligned to rows of ``X``.
    cases : ArrayLike | None, optional
        Choice-situation identifiers aligned to rows of ``X``.
    panels : ArrayLike | None, optional
        Decision-maker identifiers aligned to rows of ``X``.
    dems : ArrayLike | None, optional
        Panel-level demographics for array-style prediction. When
        ``dem_panel_ids`` is omitted, rows must be in sorted unique panel-ID
        order.
    dem_panel_ids : ArrayLike | None, optional
        Panel IDs aligned with rows of ``dems``. The parser uses these IDs to
        validate and reorder demographic rows.
    past_choices : PastChoicesData or tabular data, optional
        Historical choices used to condition latent-class membership probabilities.
        Pass a :class:`~lcl.options.PastChoicesData` instance for array-style
        inputs, or a Polars/Pandas/DataFrame-like object containing the fitted
        model's identifiers, choices, and historical utility inputs. A subset
        of prediction panels is allowed; missing histories retain their prior.
        Membership priors always use prediction demographics. History only
        needs demographic columns when they enter historical utility.
    data : object | None, optional
        Long-format prediction data. If provided, the fitted encoder parses this
        data using the original empirical specification.
    dems_data : object | None, optional
        Optional panel-level demographics to merge into ``data`` during prediction.
    past_choices_dems_data : object | None, optional
        Optional panel-level demographics to merge into tabular ``past_choices``.
        Cannot be used with :class:`~lcl.options.PastChoicesData`.
    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.
        Individual probabilities do not depend on these weights.

    Returns
    -------
    :class:`~lcl._prediction.LCLPrediction`
        Prediction results, including choice probabilities, consumer surplus,
        panel-level WTP values, and the class probabilities used for prediction.

    Raises
    ------
    ValueError
        If required prediction identifiers are missing, if fitted latent-class
        parameters are unavailable, or if ``past_choices_dems_data`` is provided
        without tabular ``past_choices``.
    """
    if data is not None and any(
        value is not None for value in (X, alts, cases, panels, dems, dem_panel_ids)
    ):
        raise ValueError("Pass either tabular data or prediction arrays, not both.")
    if data is None and dems_data is not None:
        raise ValueError(
            "dems_data requires tabular data; use dems for array prediction."
        )
    if past_choices is None and past_choices_dems_data is not None:
        raise ValueError(
            "past_choices_dems_data can only be used when past_choices is provided."
        )
    partition_data_df = None
    raw_prediction_data = None
    if data is not None:
        parsed_predict = self.model._transform_data(data, dems_data=dems_data)
        encoder = getattr(self.model, "_encoder", None)
        if encoder is not None:
            raw_prediction_data = _aligned_raw_prediction_data(
                data, parsed_predict, encoder, dems_data
            )
            partition_data_df = _prediction_partition_data(
                data, dems_data, encoder.panels_col
            )
    else:
        if X is None or alts is None or cases is None or panels is None:
            raise ValueError(
                "Provide either data=... or X, alts, cases, and panels."
            )
        parsed_predict = _parsed_prediction_arrays(
            X=X,
            dems=dems,
            alts=alts,
            cases=cases,
            panels=panels,
            dem_panel_ids=dem_panel_ids,
            case_varnames=self.model.case_varnames,
            dem_varnames=self.model.dem_varnames,
        )
    predict_data = cast(Data, self.model._setup_data(parsed_predict)[0])
    if predict_data.num_panels is None or predict_data.panels is None:
        raise ValueError(
            "Panel identifiers are required for latent-class prediction."
        )
    betas = self.em_res.betas
    if betas is None:
        raise ValueError("Structural betas are required for prediction.")
    shares = self.em_res.shares
    if shares is None:
        raise ValueError("Class shares are required for prediction.")
    if self.em_res.thetas is not None and predict_data.dems is None:
        raise ValueError(
            "dems is required for array-style prediction because the fitted "
            "class-membership model uses demographics. Pass dem_panel_ids to "
            "validate their panel alignment."
        )

    # Retained for posterior-updated WTP inference, which differentiates
    # through the Bayes update rather than freezing the posterior.
    data_past: Data | None = None
    diff_unchosen_chosen_past: DiffUnchosenChosen | None = None
    if past_choices is not None:
        parsed_past = _parse_past_choices(
            model=self.model,
            past_choices=past_choices,
            past_choices_dems_data=past_choices_dems_data,
        )
        panel_map = jnp.asarray(
            _validate_past_choice_panels(parsed_past, parsed_predict),
            dtype=jnp.uint32,
        )
        data_past = cast(Data, self.model._setup_data(parsed_past)[0])
        assert data_past.panels is not None
        assert data_past.panels_of_cases is not None
        mapped_cases = panel_map[data_past.panels_of_cases]
        data_past = data_past._replace(
            panels=panel_map[data_past.panels],
            panels_of_cases=mapped_cases,
            num_panels=predict_data.num_panels,
            num_cases_per_panel=jnp.bincount(
                mapped_cases, length=predict_data.num_panels
            ),
            dems=predict_data.dems,
            num_dem_vars=predict_data.num_dem_vars,
        )
        diff_unchosen_chosen_past = _diff_unchosen_chosen(data_past)
        class_probs_by_panel, _ = _compute_conditional_class_probs(
            betas=betas,
            thetas=self.em_res.thetas,
            shares=shares,
            diff_unchosen_chosen=diff_unchosen_chosen_past,
            data=data_past,
        )
        class_probabilities_source = "posterior"
    elif self.em_res.thetas is not None and predict_data.dems is not None:
        class_probs_by_panel = self._get_class_probs(
            self.em_res.thetas, predict_data.dems, predict_data.num_panels
        )
        class_probabilities_source = "prior"
    else:
        class_probs_by_panel = jnp.repeat(
            shares[None, :], predict_data.num_panels, axis=0
        )
        class_probabilities_source = "prior"

    choice_probs_by_class, log_sum_exp_utility = _choice_probabilities_and_logsum(
        predict_data.X,
        betas,
        predict_data.cases,
        predict_data.num_cases,
    )

    # Ensure alpha (marginal utility of income) is correctly signed
    numeraire_idx = getattr(self.model, "numeraire_idx", None)
    if numeraire_idx is None:
        marginal_utility_income = jnp.ones(self.model.num_classes)
    else:
        marginal_utility_income = -betas[numeraire_idx, :]

    surplus_by_class = log_sum_exp_utility / marginal_utility_income[None, :]

    if numeraire_idx is not None:
        betas_sans_numeraire = jnp.delete(betas, numeraire_idx, axis=0)
        wtp_alt_vars_by_class = betas_sans_numeraire / marginal_utility_income
        wtp_alt_vars_by_panel = class_probs_by_panel @ wtp_alt_vars_by_class.T
        schema = [
            var for var in self.model.case_varnames if var != self.model.numeraire
        ]
    else:
        wtp_alt_vars_by_panel = jnp.empty((predict_data.num_panels, 0))
        schema = []

    panel_first_rows = predict_data.panels != jnp.roll(predict_data.panels, shift=1)
    panel_first_rows = panel_first_rows.at[0].set(True)
    panels_unique = onp.array(parsed_predict.original_panels[panel_first_rows])
    encoder = getattr(self.model, "_encoder", None)
    resolved_panel_weights = resolve_panel_weights(
        panel_weights,
        panels_unique,
        raw_prediction_data,
        encoder.panels_col if encoder is not None else "panels",
    )
    wtp_alt_vars_by_panel_df = pl.DataFrame(
        onp.array(wtp_alt_vars_by_panel), schema=schema
    ).with_columns(pl.Series("panels", panels_unique))

    if (
        predict_data.num_cases_per_panel is None
        or predict_data.panels_of_cases is None
    ):
        raise ValueError(
            "Panel identifiers are required for latent-class prediction."
        )
    conditional_surplus = jnp.einsum(
        "np,np->n",
        class_probs_by_panel[predict_data.panels_of_cases],
        surplus_by_class,
    )

    unconditional_choice_probs = jnp.sum(
        class_probs_by_panel[predict_data.panels] * choice_probs_by_class, axis=1
    )

    predicted_probs_df = pl.DataFrame(
        {
            "panels": parsed_predict.original_panels,
            "cases": parsed_predict.original_cases,
            "alts": parsed_predict.original_alts,
            "choice_probs": onp.array(
                unconditional_choice_probs, dtype=onp.float64
            ),
        }
    )

    first_case_rows = predict_data.cases != jnp.roll(predict_data.cases, shift=1)
    first_case_rows = first_case_rows.at[0].set(True)
    surplus_df = pl.DataFrame(
        {
            "panels": onp.array(parsed_predict.original_panels[first_case_rows]),
            "cases": onp.array(parsed_predict.original_cases[first_case_rows]),
            "surplus": onp.array(conditional_surplus, dtype=onp.float64),
        }
    )

    return LCLPrediction(
        predicted_probs_df=predicted_probs_df,
        surplus_df=surplus_df,
        wtp_alt_vars_by_panel_df=wtp_alt_vars_by_panel_df,
        predict_data=predict_data,
        results=self,
        class_probs_by_panel=class_probs_by_panel,
        class_probabilities_source=class_probabilities_source,
        partition_data_df=partition_data_df,
        original_alts=parsed_predict.original_alts,
        original_cases=parsed_predict.original_cases,
        original_panels=parsed_predict.original_panels,
        raw_prediction_data=raw_prediction_data,
        panel_weights=resolved_panel_weights,
        past_diff_unchosen_chosen=diff_unchosen_chosen_past,
        past_data=data_past,
    )

spec_summary()

Return a human-readable model specification summary.

Source code in src/lcl/_results.py
def spec_summary(self) -> str:
    """Return a human-readable model specification summary."""
    spec = getattr(self.model, "spec", None)
    if spec is not None:
        return "\n".join(spec.summary_lines())

    lines = [
        "Latent-class conditional logit",
        f"Classes: {self.model.num_classes}",
        "",
        "Utility variables:",
    ]
    for variable in self.model.case_varnames:
        suffix = ""
        if variable == self.model.numeraire:
            suffix = (
                f" [negative, min_abs={self._param_packing.numeraire_min_abs:g}]"
            )
        label = _model_variable_label(self.model, variable)
        variable_text = label if label == variable else f"{label} ({variable})"
        lines.append(f"  {variable_text}{suffix}")
    lines.append("")
    lines.append("Class-membership variables:")
    if self.model.dem_varnames:
        for variable in self.model.dem_varnames:
            label = _model_variable_label(self.model, variable)
            variable_text = label if label == variable else f"{label} ({variable})"
            lines.append(f"  {variable_text}")
    else:
        lines.append("  none")
    return "\n".join(lines)

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

Alias for :meth:summarize_betas.

Source code in src/lcl/_results.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', "Means (\\beta's)", "Standard deviations (\\sigma's)"), num_decimals=3, *, show=True)

Print and return population-level coefficient moments.

Parameters:

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

Column labels used in the printed LaTeX and terminal tables.

("Variable", ...)
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-moment table. The variable column preserves raw model names; label contains presentation labels used for printing.

Source code in src/lcl/_results.py
def summarize_betas(
    self,
    header: tuple[str, str, str] = (
        "Variable",
        r"Means (\beta's)",
        r"Standard deviations (\sigma's)",
    ),
    num_decimals: int = 3,
    *,
    show: bool = True,
) -> pl.DataFrame:
    """Print and return population-level coefficient moments.

    Parameters
    ----------
    header : tuple[str, str, str], default=("Variable", ...)
        Column labels used in the 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-moment table.  The ``variable`` column preserves raw
        model names; ``label`` contains presentation labels used for printing.
    """
    summary_df = self.beta_summary()
    if show:
        log_or_print(
            logger,
            "%s",
            format_lcl_beta_summary(summary_df, header, num_decimals),
        )
    return summary_df

summarize_class_betas(num_decimals=3, *, layout='auto', include_shares=True, show=True)

Print and return class-specific utility coefficients.

:meth:summarize_betas reports the population mean and standard deviation of each coefficient; this method reports the class-specific coefficients those moments are computed from, in the same two-line estimate-over-standard-error cell.

Parameters:

Name Type Description Default
num_decimals int

Decimal places used in the printed tables.

3
layout (auto, wide, dense)

"wide" puts one variable per row and one class per column, which reads best for a handful of classes. "dense" transposes to one row per class, which keeps its width bounded by the number of utility variables and therefore stays readable at many classes. "auto" switches to "dense" at nine classes.

"auto"
include_shares bool

Append each class's aggregate share to the printed table.

True
show bool

Emit LaTeX and terminal renderings.

True

Returns:

Type Description
DataFrame

The long-format frame from :meth:class_coefficients, unchanged, so printing and programmatic use share one source of truth.

Source code in src/lcl/_results.py
def summarize_class_betas(
    self,
    num_decimals: int = 3,
    *,
    layout: Literal["auto", "wide", "dense"] = "auto",
    include_shares: bool = True,
    show: bool = True,
) -> pl.DataFrame:
    """Print and return class-specific utility coefficients.

    :meth:`summarize_betas` reports the population mean and standard
    deviation of each coefficient; this method reports the class-specific
    coefficients those moments are computed from, in the same two-line
    estimate-over-standard-error cell.

    Parameters
    ----------
    num_decimals : int, default=3
        Decimal places used in the printed tables.
    layout : {"auto", "wide", "dense"}, default="auto"
        ``"wide"`` puts one variable per row and one class per column, which
        reads best for a handful of classes.  ``"dense"`` transposes to one
        row per class, which keeps its width bounded by the number of
        utility variables and therefore stays readable at many classes.
        ``"auto"`` switches to ``"dense"`` at nine classes.
    include_shares : bool, default=True
        Append each class's aggregate share to the printed table.
    show : bool, default=True
        Emit LaTeX and terminal renderings.

    Returns
    -------
    pl.DataFrame
        The long-format frame from :meth:`class_coefficients`, unchanged, so
        printing and programmatic use share one source of truth.
    """
    table = self.class_coefficients()
    if show:
        shares = None
        share_standard_errors = None
        if include_shares:
            share_frame = self.class_shares()
            shares = share_frame["share"].to_list()
            share_standard_errors = share_frame["std_error"].to_list()
        log_or_print(
            logger,
            "%s",
            format_class_coefficients(
                table,
                self.model.num_classes,
                num_decimals,
                layout=layout,
                shares=shares,
                share_standard_errors=share_standard_errors,
            ),
        )
    return table

summarize_membership(num_decimals=3, *, layout='auto', include_shares=True, marginal_effects=True, show=True)

Print and return the class-membership demographic regression.

The reference class is printed as an explicit row (or column) of zeros rather than omitted, and the rendering carries the normalisation note, so a reader can see which class the log-odds are measured against without consulting the documentation.

Parameters:

Name Type Description Default
num_decimals int

Decimal places used in the printed tables.

3
layout (auto, wide, dense)

"dense" gives one row per class and one column per demographic variable, the layout that scales to many classes; "wide" transposes it. "auto" switches to "dense" at nine classes.

"auto"
include_shares bool

Show each class's aggregate share beside its coefficients.

True
marginal_effects bool

Also print :meth:membership_marginal_effects, which restates the same regression in probability points and without the reference-class normalisation.

True
show bool

Emit LaTeX and terminal renderings.

True

Returns:

Type Description
DataFrame

The long-format frame from :meth:membership_coefficients.

Raises:

Type Description
ValueError

If the fitted model has no demographic class-membership regression.

Source code in src/lcl/_results.py
def summarize_membership(
    self,
    num_decimals: int = 3,
    *,
    layout: Literal["auto", "wide", "dense"] = "auto",
    include_shares: bool = True,
    marginal_effects: bool = True,
    show: bool = True,
) -> pl.DataFrame:
    """Print and return the class-membership demographic regression.

    The reference class is printed as an explicit row (or column) of zeros
    rather than omitted, and the rendering carries the normalisation note, so
    a reader can see which class the log-odds are measured against without
    consulting the documentation.

    Parameters
    ----------
    num_decimals : int, default=3
        Decimal places used in the printed tables.
    layout : {"auto", "wide", "dense"}, default="auto"
        ``"dense"`` gives one row per class and one column per demographic
        variable, the layout that scales to many classes; ``"wide"``
        transposes it.  ``"auto"`` switches to ``"dense"`` at nine classes.
    include_shares : bool, default=True
        Show each class's aggregate share beside its coefficients.
    marginal_effects : bool, default=True
        Also print :meth:`membership_marginal_effects`, which restates the
        same regression in probability points and without the reference-class
        normalisation.
    show : bool, default=True
        Emit LaTeX and terminal renderings.

    Returns
    -------
    pl.DataFrame
        The long-format frame from :meth:`membership_coefficients`.

    Raises
    ------
    ValueError
        If the fitted model has no demographic class-membership regression.
    """
    if self.em_res.thetas is None:
        raise ValueError(
            "This model has no class-membership regression: it was fitted "
            "without demographics, so class shares are constant across "
            "panels. Use class_shares() instead."
        )
    table = self.membership_coefficients()
    if show:
        shares = self.class_shares()["share"].to_list() if include_shares else None
        log_or_print(
            logger,
            "%s",
            format_membership_coefficients(
                table,
                self.model.num_classes,
                num_decimals,
                layout=layout,
                shares=shares,
            ),
        )
        if marginal_effects:
            log_or_print(
                logger,
                "%s",
                format_membership_marginal_effects(
                    self.membership_marginal_effects(),
                    self.model.num_classes,
                    num_decimals,
                    layout=layout,
                ),
            )
    return table

Held-out scoring

LCLResults.loglik transforms observed choices with the fitted encoder:

total_ll = results.loglik(test_data)
panel_ll = results.loglik(test_data, per_panel=True)

The panel-level form returns original panel IDs and their log-likelihood contributions. It is also the scoring path used by cross-validation.

Summary methods return Polars frames. Pass show=False to suppress their LaTeX and terminal renderings:

summary = results.summarize_betas(show=False)
class_coefficients = results.class_coefficients()  # includes std_error
membership = results.membership_coefficients()    # class 0 is the reference
classification = results.classification_diagnostics()

parameter_names() labels covariance rows and columns exactly. converged, cov_matrix, and adjusted_bic are the canonical names shared with conditional logit; convergence, covariance, and abic are deprecated aliases.

Boundary results and diagnostics

In 0.1.42, class_coefficients() adds boundary and inference_status. beta_summary() adds inference_status and supports projected uncertainty for class-weighted coefficient means and SDs when requested at fit time.

Attribute / column Contract
result.inference_status Covariance scope: regular, conditional_on_boundary, unavailable, or skipped.
result.boundary_parameter_indices Tuple of numerically binding structural-coefficient indices, aligned with parameter_names().
result.boundary_kkt_violation Maximum feasible structural ascent per panel at those bounds. A violation above score_tol marks the fit nonconverged.
result.cov_matrix Structural covariance. In conditional/projected modes, binding-price rows and columns are zero by conditioning; individual price SEs are suppressed.
Summary inference_status critical_cone_projection, conditional_on_boundary_fallback, or the covariance status when the ordinary/conditional delta method applies.
result.boundary_summary_diagnostics Initially contains method. After projected summarization, also includes the selected constraints, multiplier tests, tuning threshold, directional-SD variables, draw count/seed, information audit, time, and any fallback reason.

The dictionary keys for selected constraints are active_parameters, strict_parameters, and weak_parameters; all use the full parameter ordering. multiplier_z follows active_parameters; each entry is an estimated KKT multiplier divided by its nuisance-adjusted, fixed-face score SD. selection_threshold records the pointwise active-set tuning rule. directional_sd_variables names coefficient spreads treated with the norm derivative at zero. simulated_dimension counts the Gaussian coordinates actually simulated; after successful projection, 0 means the summary SEs have no Monte Carlo error. It does not establish exact finite-sample inference or rule out a conditional fallback. fallback_reason is None when the projection succeeds.

Call beta_summary() before reading its projection diagnostics. A finite cov_matrix alone does not establish full boundary uncertainty: it can be conditional even when moment SEs use projection. Prediction, WTP, elasticity, and membership SEs retain that covariance's conditional interpretation. When prices are interior and far from their bounds, projected mode uses regular summary inference.

The boundary-price tutorial demonstrates this API. The method guide cites Geyer, Andrews, Kim–Stone–White, Liao–Kroer, Andrews–Soares, and Fang–Santos, and states the assumptions and conditional fallback.

Diagnostics

lcl.results.LCLDiagnostics(frame)

Structured diagnostics for a fitted latent-class model.

Parameters:

Name Type Description Default
frame DataFrame

Diagnostic checks with at least section, check, value, status, and message columns.

required

Store diagnostic checks.

Source code in src/lcl/_diagnostics.py
def __init__(self, frame: pl.DataFrame) -> None:
    """Store diagnostic checks."""
    self._frame = frame

__repr__()

Return a compact textual representation.

Source code in src/lcl/_diagnostics.py
def __repr__(self) -> str:
    """Return a compact textual representation."""
    n_warn = self._frame.filter(pl.col("status") != "ok").height
    return f"LCLDiagnostics(checks={self._frame.height}, warnings={n_warn})"

print()

Print a compact diagnostics table.

Source code in src/lcl/_diagnostics.py
def print(self) -> None:
    """Print a compact diagnostics table."""
    rows = self._frame.select(["section", "check", "status", "value", "message"])
    print(tabulate(rows.iter_rows(), headers=rows.columns, tablefmt="simple"))

to_frame()

Return diagnostics as a Polars DataFrame.

Source code in src/lcl/_diagnostics.py
def to_frame(self) -> pl.DataFrame:
    """Return diagnostics as a Polars DataFrame."""
    return self._frame

Prediction and counterfactuals

Tabular prediction is preferred because it reuses the fitted encoder:

prediction = results.predict(data=counterfactual_data)
shares = prediction.market_shares()
aggregate = prediction.aggregate_elasticities(["cost", "time"])

Pass panel_weights= to predict as a panel-keyed mapping, a prediction-data column name, or a vector in sorted prediction-panel order. WTP supports se="delta", se="bootstrap" (an asymptotic parametric bootstrap), and se="none". Both inference methods propagate uncertainty through the Bayesian update when past_choices is supplied. History can cover a subset of prediction consumers; prediction.class_membership() reports the probability source and history count for each consumer. Prediction demographics supply the prior.

Surplus frames include surplus_units (money with a numeraire, otherwise utils). Use baseline_prediction.surplus_change(counterfactual_prediction) for the identified welfare change rather than comparing unnormalised levels. Changes check model, consumer/occasion identity, and weights, and include change_identified. Monetary summaries require a linear numeraire without extra price transforms or interactions. marginal_wtp("quality") evaluates the full raw-attribute derivative at each offered profile; compute_wtp aggregates it by consumer and demographic group. See the economic definitions and worked examples.

For array-style prediction, supply dem_panel_ids with dems so demographic rows can be validated and reordered. Tabular and array prediction inputs cannot be combined in one call. Without those IDs, demographic rows must follow sorted unique panel-ID order.

lcl.results.LCLPrediction(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

Latent-class prediction with partitioned WTP inference.

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

class_membership()

Return prior and prediction probabilities, with history counts by consumer.

One row per panel and class; class IDs follow the zero-based result API. Consumers with zero historical cases retain their demographic prior.

Source code in src/lcl/_prediction.py
def class_membership(self) -> pl.DataFrame:
    """Return prior and prediction probabilities, with history counts by consumer.

    One row per panel and class; class IDs follow the zero-based result API.
    Consumers with zero historical cases retain their demographic prior.
    """
    data = self.predict_data
    assert data.num_panels is not None and self.class_probs_by_panel is not None
    _, prior = self.results._betas_and_class_probs(
        self.results.flat_params, data.dems, data.num_panels
    )
    history_counts = (
        onp.zeros(data.num_panels, dtype=int)
        if self.past_data is None
        else onp.asarray(self.past_data.num_cases_per_panel)
    )
    classes = self.results.model.num_classes
    return pl.DataFrame(
        {
            "panels": onp.repeat(
                self.wtp_alt_vars_by_panel["panels"].to_numpy(), classes
            ),
            "class": onp.tile(onp.arange(classes), data.num_panels),
            "prior_probability": onp.asarray(prior).ravel(),
            "probability": onp.asarray(self.class_probs_by_panel).ravel(),
            "history_cases": onp.repeat(history_counts, classes),
            "probability_source": onp.repeat(
                onp.where(history_counts > 0, "posterior", "prior"), classes
            ),
        }
    )

compute_wtp(*wtp_requests, partition_data=None, panel_col='panels', num_decimals=4, class_probabilities='stored', se='delta', bootstrap_draws=500, bootstrap_seed=0, show=True)

Compute the Marginal Willingness-to-Pay (WTP) across demographic partitions.

Evaluates raw-attribute utility derivatives divided by each class's own negative cost coefficient for subsets of decision-makers. Interactions and transforms enter the numerator through the utility-formula chain rule. Expanded design-column targets instead hold the other design columns fixed. Outputs formatted LaTeX and terminal summary tables, including analytical standard errors derived via the Delta Method.

Parameters:

Name Type Description Default
*wtp_requests WTPRequest | Iterable[WTPRequest]

One or more configuration objects specifying the target variable, the demographic partitioning variable, and the binning strategy (e.g., quintiles, categorical, custom breaks, or a dummy-coded categorical factor).

()
partition_data object | None

Optional panel-level or long-format tabular data containing partitioning variables that were not included in the fitted class-membership specification. Values must be constant within each panel.

None
panel_col str

Panel identifier column in partition_data.

"panels"
num_decimals int

Number of decimal places used in printed WTP tables.

4
class_probabilities (stored, prior, posterior)

Class-membership probabilities used for WTP/tradeoff point estimates. "stored" uses the probabilities already attached to this prediction object, including Bayesian posterior updates from past_choices. "prior" recomputes demographics-only class probabilities. "posterior" requires that prediction was created with past_choices.

"stored"
se (delta, bootstrap, none)

Standard-error method. Delta-method and asymptotic parametric-bootstrap standard errors are available for prior class probabilities. When the prediction used past_choices, both methods differentiate through the Bayesian class update, so the reported uncertainty reflects the same posterior as the point estimate. Note that partitions built from the data (quintiles, custom breaks) are treated as fixed, so standard errors are conditional on the realized partition and on the demographic design.

"delta"
bootstrap_draws int

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

500
bootstrap_seed int

Reproducible random seed for parametric-bootstrap draws.

0
show bool

Emit LaTeX and terminal renderings for each request.

True

Returns:

Type Description
dict[str, DataFrame]

Summary tables keyed by their printed titles. Each table preserves raw variable names in variable and partition_variable and includes presentation labels in label and partition_label.

Notes

Derivatives are averaged equally across offered alternatives within each case, then across cases within a panel. Panel weights apply across panels. This matters when WTP varies across the evaluation profiles. The returned wtp_alt_vars_by_panel attribute and :meth:wtp_by_class instead contain expanded-design coefficient ratios. Quintile cutoffs are unweighted sample quantiles; inference treats cutoffs and demographic designs as fixed.

Raises:

Type Description
ValueError

If the parent model was not estimated with a specified numeraire constraint.

Source code in src/lcl/_prediction.py
def compute_wtp(
    self,
    *wtp_requests: WTPRequest | Iterable[WTPRequest],
    partition_data: object | None = None,
    panel_col: str = "panels",
    num_decimals: int = 4,
    class_probabilities: Literal["stored", "prior", "posterior"] = "stored",
    se: Literal["delta", "bootstrap", "none"] = "delta",
    bootstrap_draws: int = 500,
    bootstrap_seed: int = 0,
    show: bool = True,
) -> dict[str, pl.DataFrame]:
    """Compute the Marginal Willingness-to-Pay (WTP) across demographic partitions.

    Evaluates raw-attribute utility derivatives divided by each class's own
    negative cost coefficient for subsets of decision-makers. Interactions
    and transforms enter the numerator through the utility-formula chain rule.
    Expanded design-column targets instead hold the other design columns fixed.
    Outputs formatted LaTeX and terminal summary tables, including analytical
    standard errors derived via the Delta Method.

    Parameters
    ----------
    *wtp_requests : WTPRequest | Iterable[WTPRequest]
        One or more configuration objects specifying the target variable,
        the demographic partitioning variable, and the binning strategy (e.g.,
        quintiles, categorical, custom breaks, or a dummy-coded categorical
        factor).
    partition_data : object | None, optional
        Optional panel-level or long-format tabular data containing partitioning
        variables that were not included in the fitted class-membership
        specification. Values must be constant within each panel.
    panel_col : str, default="panels"
        Panel identifier column in ``partition_data``.
    num_decimals : int, default=4
        Number of decimal places used in printed WTP tables.
    class_probabilities : {"stored", "prior", "posterior"}, default="stored"
        Class-membership probabilities used for WTP/tradeoff point estimates.
        ``"stored"`` uses the probabilities already attached to this
        prediction object, including Bayesian posterior updates from
        ``past_choices``. ``"prior"`` recomputes demographics-only class
        probabilities. ``"posterior"`` requires that prediction was created
        with ``past_choices``.
    se : {"delta", "bootstrap", "none"}, default="delta"
        Standard-error method. Delta-method and asymptotic parametric-bootstrap
        standard errors are available for prior class probabilities.
        When the prediction used ``past_choices``, both methods
        differentiate through the Bayesian class update, so the reported
        uncertainty reflects the same posterior as the point estimate.  Note
        that partitions built from the data (quintiles, custom breaks) are
        treated as fixed, so standard errors are conditional on the realized
        partition and on the demographic design.
    bootstrap_draws : int, default=500
        Number of asymptotic parameter draws for ``se="bootstrap"``.
    bootstrap_seed : int, default=0
        Reproducible random seed for parametric-bootstrap draws.
    show : bool, default=True
        Emit LaTeX and terminal renderings for each request.

    Returns
    -------
    dict[str, pl.DataFrame]
        Summary tables keyed by their printed titles.  Each table preserves
        raw variable names in ``variable`` and ``partition_variable`` and
        includes presentation labels in ``label`` and ``partition_label``.

    Notes
    -----
    Derivatives are averaged equally across offered alternatives within each
    case, then across cases within a panel. Panel weights apply across panels.
    This matters when WTP varies across the evaluation profiles. The returned
    ``wtp_alt_vars_by_panel`` attribute and :meth:`wtp_by_class` instead contain
    expanded-design coefficient ratios. Quintile cutoffs are unweighted
    sample quantiles; inference treats cutoffs and demographic designs as fixed.

    Raises
    ------
    ValueError
        If the parent model was not estimated with a specified numeraire constraint.
    """
    if se not in {"delta", "bootstrap", "none"}:
        raise ValueError("se must be 'delta', 'bootstrap', or 'none'.")
    if class_probabilities not in {"stored", "prior", "posterior"}:
        raise ValueError(
            "class_probabilities must be 'stored', 'prior', or 'posterior'."
        )
    if (
        class_probabilities == "posterior"
        and self.class_probabilities_source != "posterior"
    ):
        raise ValueError(
            "class_probabilities='posterior' requires predict(..., past_choices=...)."
        )
    use_posterior = (
        class_probabilities in {"stored", "posterior"}
        and self.class_probabilities_source == "posterior"
    )
    if (
        se in {"delta", "bootstrap"}
        and use_posterior
        and (self.past_data is None or self.past_diff_unchosen_chosen is None)
    ):
        raise ValueError(
            "Posterior-updated WTP inference needs the past-choice design "
            "that produced the posterior. Recreate the prediction with "
            "predict(..., past_choices=...)."
        )

    # We rely on the explicitly tracked numeraire index from _pre_fit
    if getattr(self.results.model, "numeraire_idx", None) is None:
        raise ValueError("A numeraire must be defined to compute WTP.")

    cost_idx = self.results.model.numeraire_idx
    if self.predict_data.panels is None or self.predict_data.num_panels is None:
        raise ValueError("Panel identifiers are required to compute WTP.")

    requests = _flatten_wtp_requests(wtp_requests)
    if not requests:
        return {}

    self._require_valid_numeraire()
    # Partition values must come from demographics, never from a WTP
    # coefficient column that happens to have the same name.
    df_with_idx = self.wtp_alt_vars_by_panel.select("panels").with_row_index(
        "panel_idx"
    )

    if (
        self.predict_data.dems is not None
        and self.results.model.dem_varnames is not None
    ):
        dems_df = pl.DataFrame(
            onp.array(self.predict_data.dems),
            schema=self.results.model.dem_varnames,
        ).with_row_index("panel_idx")

        df_with_idx = df_with_idx.join(dems_df, on="panel_idx")

    if partition_data is not None:
        requested = _partition_columns(requests)
        external = _coerce_partition_data(partition_data, panel_col, requested)
        df_with_idx = df_with_idx.drop(
            [c for c in requested if c in df_with_idx.columns]
        )
        df_with_idx = df_with_idx.join(external, on="panels", how="left")

    partition_cols = _partition_columns(requests)
    missing_partition_cols = [
        col for col in partition_cols if col not in df_with_idx.columns
    ]
    if missing_partition_cols:
        source_partition_data = partition_data
        source_panel_col = panel_col
        if source_partition_data is None and self.partition_data is not None:
            source_partition_data = self.partition_data
            source_panel_col = "panels"

        if source_partition_data is None:
            raise ValueError(
                "WTP partition columns were not found in the fitted/prediction "
                "demographics: "
                f"{missing_partition_cols}. Pass partition_data=... for "
                "panel-level grouping variables outside the model specification."
            )
        external_partitions = _coerce_partition_data(
            source_partition_data, source_panel_col, missing_partition_cols
        )
        df_with_idx = df_with_idx.join(external_partitions, on="panels", how="left")
        has_missing_partition = df_with_idx.select(
            pl.any_horizontal(pl.col(missing_partition_cols).is_null()).any()
        ).item()
        if has_missing_partition:
            raise ValueError(
                "partition_data is missing partition values for one or more "
                "prediction panels."
            )
    if df_with_idx.select(
        pl.any_horizontal(pl.col(partition_cols).is_null()).any()
    ).item():
        raise ValueError(
            "WTP partition values cannot be missing for prediction panels."
        )

    summary_tables: dict[str, pl.DataFrame] = {}

    for req in requests:
        partition_type = req.partition_type
        if not isinstance(partition_type, PartitionType):
            partition_type = PartitionType(partition_type)

        partitioned_df = _apply_wtp_partition(df_with_idx, req)
        if "_partition_order" in partitioned_df.columns:
            partitioned_df = partitioned_df.sort("_partition_order")
        panel_derivatives = self._wtp_panel_derivative(req.alt_var)
        # Kept as an internal fallback for callers of the coefficient-ratio
        # helper; panel_derivatives supplies the full formula derivative.
        target_idx = 0
        target_label = self.results.model.variable_label(req.alt_var)
        partition_label = self.results.model.variable_label(req.demographic_var)
        selected_class_probs = None
        if se == "none":
            selected_class_probs = self._class_probs_for_wtp(class_probabilities)
        # Differentiating through the Bayes update keeps the reported
        # uncertainty consistent with the point estimate: the posterior is a
        # smooth function of the same coefficients, not a fixed constant.
        posterior_kwargs: dict[str, Any] = (
            {
                "past_diff_unchosen_chosen": self.past_diff_unchosen_chosen,
                "past_data": self.past_data,
            }
            if use_posterior
            else {"past_diff_unchosen_chosen": None, "past_data": None}
        )
        summary_rows = []

        for partition_name, subset_df in partitioned_df.group_by(
            "Partition", maintain_order=True
        ):
            subset_panel_indices = jnp.array(
                subset_df["panel_idx"].to_numpy(), dtype=jnp.int32
            )
            subset_panel_weights = jnp.asarray(
                self.panel_weights[onp.asarray(subset_panel_indices)]
            )
            if float(jnp.sum(subset_panel_weights)) <= 0.0:
                raise ValueError(
                    "Every WTP partition must have positive total panel weight."
                )

            if se == "delta":
                mean_wtp, se_val = self.results._apply_delta_method(
                    self._compute_subset_mean_wtp,
                    self.results.flat_params,
                    target_idx=target_idx,
                    cost_idx=cost_idx,
                    subset_panel_indices=subset_panel_indices,
                    subset_panel_weights=subset_panel_weights,
                    dems=self.predict_data.dems,
                    num_panels=self.predict_data.num_panels,
                    panel_derivatives=panel_derivatives,
                    **posterior_kwargs,
                )
                se_float = float(se_val)
            elif se == "bootstrap":
                mean_wtp = self._compute_subset_mean_wtp(
                    self.results.flat_params,
                    target_idx=target_idx,
                    cost_idx=cost_idx,
                    subset_panel_indices=subset_panel_indices,
                    subset_panel_weights=subset_panel_weights,
                    dems=self.predict_data.dems,
                    num_panels=self.predict_data.num_panels,
                    panel_derivatives=panel_derivatives,
                    **posterior_kwargs,
                )
                se_val = self.results._parametric_bootstrap_se(
                    self._compute_subset_mean_wtp,
                    self.results.flat_params,
                    target_idx=target_idx,
                    cost_idx=cost_idx,
                    subset_panel_indices=subset_panel_indices,
                    subset_panel_weights=subset_panel_weights,
                    dems=self.predict_data.dems,
                    num_panels=self.predict_data.num_panels,
                    panel_derivatives=panel_derivatives,
                    draws=bootstrap_draws,
                    seed=bootstrap_seed,
                    requires_negative_numeraire=True,
                    **posterior_kwargs,
                )
                se_float = float(se_val)
            else:
                if selected_class_probs is None:
                    raise ValueError("Class probabilities were not available.")
                mean_wtp = self._compute_subset_mean_wtp_from_class_probs(
                    target_idx=target_idx,
                    cost_idx=cost_idx,
                    subset_panel_indices=subset_panel_indices,
                    subset_panel_weights=subset_panel_weights,
                    class_probs=selected_class_probs,
                    panel_derivatives=panel_derivatives,
                )
                se_float = float("nan")

            summary_rows.append(
                {
                    "variable": req.alt_var,
                    "label": target_label,
                    "partition_variable": req.demographic_var,
                    "partition_label": partition_label,
                    req.demographic_var: str(_partition_label(partition_name)),
                    "Mean_Marginal_WTP": float(mean_wtp),
                    "Standard_Error": se_float,
                    "Class_Probabilities": class_probabilities,
                    "SE_Method": se,
                    "Panel_Count": subset_df.height,
                    "Effective_Panel_Weight": float(
                        onp.asarray(subset_panel_weights).sum()
                    ),
                }
            )

        res_df = pl.DataFrame(summary_rows)
        partition_desc = (
            "dummy-coded categorical"
            if req.dummy_vars is not None
            else partition_type.value
        )
        title = (
            f"Marginal WTP for {target_label} by "
            f"{partition_label} ({partition_desc})"
        )
        summary_tables[title] = res_df
        if show:
            log_or_print(
                logger,
                "%s",
                format_wtp_table(
                    title,
                    res_df,
                    req.demographic_var,
                    partition_label,
                    num_decimals,
                ),
            )

    return summary_tables

denominator_diagnostics()

Return denominator levels, SEs, and marginal Gaussian crossing probabilities.

Probabilities above the configured bound drive the deterministic 0.001 bootstrap screen; probabilities above zero describe denominator sign uncertainty. These are diagnostics, not joint coverage guarantees. Unavailable covariance produces NaN uncertainty diagnostics.

Source code in src/lcl/_prediction.py
def denominator_diagnostics(self) -> pl.DataFrame:
    """Return denominator levels, SEs, and marginal Gaussian crossing probabilities.

    Probabilities above the configured bound drive the deterministic 0.001
    bootstrap screen; probabilities above zero describe denominator sign
    uncertainty. These are diagnostics, not joint coverage guarantees.
    Unavailable covariance produces NaN uncertainty diagnostics.
    """
    numeraire_idx = getattr(self.results.model, "numeraire_idx", None)
    if numeraire_idx is None:
        raise ValueError("A numeraire must be defined to compute diagnostics.")
    betas = self.results.em_res.betas
    if betas is None:
        raise ValueError("Structural betas are required.")
    denominator = -betas[numeraire_idx, :]
    return pl.DataFrame(
        {
            "class": list(range(self.results.model.num_classes)),
            "denominator": [self.results.model.numeraire]
            * self.results.model.num_classes,
            "denominator_label": [
                self.results.model.variable_label(str(self.results.model.numeraire))
            ]
            * self.results.model.num_classes,
            "denominator_value": onp.asarray(denominator),
            "abs_denominator": onp.asarray(jnp.abs(denominator)),
            **self._denominator_uncertainty(),
            "min_abs_floor": [self.results._param_packing.numeraire_min_abs]
            * self.results.model.num_classes,
        }
    )

tradeoff(*wtp_requests, **kwargs)

Alias for :meth:compute_wtp with more neutral terminology.

Source code in src/lcl/_prediction.py
def tradeoff(
    self,
    *wtp_requests: WTPRequest | Iterable[WTPRequest],
    **kwargs: Any,
) -> dict[str, pl.DataFrame]:
    """Alias for :meth:`compute_wtp` with more neutral terminology."""
    return self.compute_wtp(*wtp_requests, **kwargs)

wtp_by_class(target=None)

Return class-specific WTP/tradeoff ratios.

Parameters:

Name Type Description Default
target str | None

Optional target variable to filter. By default, all non-numeraire alternative-specific variables are returned.

None

Returns:

Type Description
DataFrame

Class-specific ratios beta_target / -beta_numeraire with raw variable names, display labels, and denominator diagnostics.

Source code in src/lcl/_prediction.py
def wtp_by_class(self, target: str | None = None) -> pl.DataFrame:
    """Return class-specific WTP/tradeoff ratios.

    Parameters
    ----------
    target : str | None, optional
        Optional target variable to filter.  By default, all non-numeraire
        alternative-specific variables are returned.

    Returns
    -------
    pl.DataFrame
        Class-specific ratios ``beta_target / -beta_numeraire`` with raw
        variable names, display labels, and denominator diagnostics.
    """
    self._require_valid_numeraire()
    numeraire_idx = getattr(self.results.model, "numeraire_idx", None)
    if numeraire_idx is None:
        raise ValueError("A numeraire must be defined to compute WTP.")
    betas = self.results.em_res.betas
    if betas is None:
        raise ValueError("Structural betas are required.")

    denominator = -betas[numeraire_idx, :]
    if target is not None and (
        target not in self.results.model.case_varnames
        or target == self.results.model.numeraire
    ):
        raise ValueError(
            f"Unknown non-numeraire utility-design variable: {target!r}."
        )
    rows = []
    for var_idx, variable in enumerate(self.results.model.case_varnames):
        if var_idx == numeraire_idx:
            continue
        if target is not None and variable != target:
            continue
        ratios = betas[var_idx, :] / denominator
        for class_idx in range(self.results.model.num_classes):
            rows.append(
                {
                    "variable": variable,
                    "label": self.results.model.variable_label(variable),
                    "denominator": self.results.model.numeraire,
                    "denominator_label": self.results.model.variable_label(
                        str(self.results.model.numeraire)
                    ),
                    "class": class_idx,
                    "tradeoff": float(ratios[class_idx]),
                    "denominator_value": float(denominator[class_idx]),
                }
            )
    return pl.DataFrame(rows)

lcl.options.WTPRequest(alt_var, demographic_var, partition_type, bins=None, dummy_vars=None, dummy_labels=None, base_category='base') dataclass

Configuration for a partitioned marginal willingness-to-pay summary.

Parameters:

Name Type Description Default
alt_var str

Raw utility attribute or expanded design-column target.

required
demographic_var str

Panel-level grouping column, or a descriptive name for a dummy-coded factor.

required
partition_type PartitionType or str

"categorical", "quintiles", or "custom_breaks".

required
bins list[float] | None

Finite, strictly increasing cutpoints for "custom_breaks" only. An empty list creates one group; integer bin counts are not supported.

None
dummy_vars list[str] | None

Mutually exclusive binary columns for a categorical partition.

None
dummy_labels list[str] | None

Distinct labels for dummy_vars, defaulting to their column names.

None
base_category str

Label for panels with all dummy columns zero. It must differ from the other dummy labels. Used only for dummy-coded partitions.

"base"

__post_init__()

Normalize and validate the partition request.

Source code in src/lcl/options.py
def __post_init__(self) -> None:
    """Normalize and validate the partition request."""
    if not isinstance(self.partition_type, PartitionType):
        try:
            self.partition_type = PartitionType(self.partition_type)
        except ValueError:
            valid_options = [item.value for item in PartitionType]
            raise ValueError(
                f"Invalid partition type: {self.partition_type}\n"
                f"Must be one of {valid_options}"
            ) from None
    if self.partition_type == PartitionType.CUSTOM_BREAKS and not isinstance(
        self.bins, list
    ):
        raise ValueError(
            "When partition_type is 'custom_breaks', bins must be breakpoints."
        )
    if self.partition_type != PartitionType.CUSTOM_BREAKS and self.bins is not None:
        raise ValueError("bins is only used with partition_type='custom_breaks'.")
    if self.bins is not None and not all(math.isfinite(x) for x in self.bins):
        raise ValueError("Custom WTP breakpoints must be finite.")
    if self.dummy_labels is not None and self.dummy_vars is None:
        raise ValueError("dummy_labels requires dummy_vars.")
    if isinstance(self.bins, list) and any(
        right <= left for left, right in zip(self.bins, self.bins[1:])
    ):
        raise ValueError("Custom WTP breakpoints must be strictly increasing.")
    if self.dummy_vars is not None:
        if not self.dummy_vars:
            raise ValueError("dummy_vars must contain at least one column name.")
        if len(set(self.dummy_vars)) != len(self.dummy_vars):
            raise ValueError("dummy_vars cannot contain duplicate column names.")
        if self.partition_type != PartitionType.CATEGORICAL:
            raise ValueError(
                "Dummy-coded WTP partitions require partition_type='categorical'."
            )
        labels = (
            self.dummy_labels if self.dummy_labels is not None else self.dummy_vars
        )
        if len(set([self.base_category, *labels])) != len(labels) + 1:
            raise ValueError(
                "Dummy partition labels and base_category must be distinct."
            )
        if self.dummy_labels is not None and len(self.dummy_labels) != len(
            self.dummy_vars
        ):
            raise ValueError("dummy_labels must have one label per dummy column.")

lcl.options.PartitionType

Bases: str, Enum

Supported binning strategies for WTP analysis.

__str__()

Return the public value, including on Python 3.10.

Source code in src/lcl/options.py
def __str__(self) -> str:
    """Return the public value, including on Python 3.10."""
    return self.value

lcl.options.PastChoicesData(X, y, alts, cases, panels, dems=None, dem_panel_ids=None) dataclass

Array-style historical choices used to update class membership.

Parameters:

Name Type Description Default
X (array - like, shape(rows, alt_vars))

Historical utility design in fitted expanded-column order.

required
y (array - like, shape(rows))

Binary historical choices, exactly one per (panel, case).

required
alts (array - like, shape(rows))

Original identifiers aligned with X. History may cover a subset of prediction panels; case IDs need only be unique within a panel.

required
cases (array - like, shape(rows))

Original identifiers aligned with X. History may cover a subset of prediction panels; case IDs need only be unique within a panel.

required
panels (array - like, shape(rows))

Original identifiers aligned with X. History may cover a subset of prediction panels; case IDs need only be unique within a panel.

required
dems (array - like, shape(panels, dem_vars))

Historical demographics retained for input compatibility. Membership priors use prediction demographics. Utility interactions must already be included in X.

None
dem_panel_ids (array - like, shape(panels))

IDs identifying rows of dems. Without these, demographic rows follow sorted unique historical panel-ID order.

None