Skip to content
Merged

V3 #2

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
name: Publish to PyPI

on:
release:
types: [published]

jobs:
pypi-publish:
name: Upload release to PyPI
runs-on: ubuntu-latest
environment:
name: multe
url: https://pypi.org/p/multe
permissions:
id-token: write # IMPORTANT: this permission is mandatory for trusted publishing
contents: read

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.x"

- name: Install build dependencies
run: |
python -m pip install --upgrade pip
pip install build

- name: Build package
run: python -m build

- name: Publish package distributions to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
13 changes: 11 additions & 2 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ jobs:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]

steps:
- uses: actions/checkout@v4
Expand All @@ -28,6 +28,15 @@ jobs:
python -m pip install --upgrade pip
pip install -e ".[dev]"

- name: Lint with Ruff
run: |
ruff check .
ruff format --check .

- name: Type check with Mypy
run: |
mypy multe/

- name: Run tests with pytest
run: |
pytest tests/ -v --tb=short
pytest tests/ -v --tb=short --cov=multe --cov-report=term-missing
22 changes: 22 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files

- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.14.6
hooks:
# Run the linter.
- id: ruff-check
types_or: [ python, pyi ]
args: [ --fix ]
# Run the formatter.
- id: ruff-format
types_or: [ python, pyi ]
2 changes: 1 addition & 1 deletion .python-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
3.13
3.14
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Multe: Multichoice Logit Estimation

[![Python Version](https://img.shields.io/badge/python-3.9%2B-blue.svg)](https://www.python.org/downloads/)
[![Python Version](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/downloads/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Tests](https://github.com/tmonk/multe/actions/workflows/tests.yml/badge.svg)](https://github.com/tmonk/multe/actions/workflows/tests.yml)
[![PyPI version](https://img.shields.io/pypi/v/multe)](https://pypi.org/project/multe/)
Expand Down Expand Up @@ -111,4 +111,4 @@ Returns: `X, y_single, y_dual, true_beta`

## Acknowledgements

Many thanks to [Alan Manning](https://www.alan-manning.com/) for his guidance and support with this project.
Many thanks to [Alan Manning](https://www.alan-manning.com/) for his guidance and support with this project.
32 changes: 17 additions & 15 deletions examples/basic_example.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,36 +9,34 @@
"""

import numpy as np
from scipy.optimize import minimize
from scipy import stats
from scipy.optimize import minimize

from multe import MultichoiceLogit, simulate_data


def main():
# Settings
N = 2000 # Number of observations
J = 4 # Number of alternatives
K = 3 # Number of covariates
J = 4 # Number of alternatives
K = 3 # Number of covariates

print(f"Simulating Data (N={N}, J={J}, K={K})...")
X, y_single, y_dual, true_beta = simulate_data(
N, J, K, mix_ratio=0.5, seed=42
)
X, y_single, y_dual, true_beta = simulate_data(N, J, K, mix_ratio=0.5, seed=42)

print("Starting Estimation...")
model = MultichoiceLogit(J, K)

# Initial guess (zeros)
init_beta = np.zeros((J-1) * K)
init_beta = np.zeros((J - 1) * K)

res = minimize(
fun=model.neg_log_likelihood,
jac=model.gradient,
x0=init_beta,
args=(X, y_single, y_dual),
method='L-BFGS-B',
options={'disp': True, 'gtol': 1e-5}
method="L-BFGS-B",
options={"disp": True, "gtol": 1e-5},
)

print("\nOptimization Success:", res.success)
Expand All @@ -47,17 +45,19 @@ def main():
print("Computing Standard Errors...")
std_errs = model.compute_standard_errors(res.x, X, y_single, y_dual)

est_beta = res.x.reshape(J-1, K)
std_errs_reshaped = std_errs.reshape(J-1, K)
est_beta = res.x.reshape(J - 1, K)
std_errs_reshaped = std_errs.reshape(J - 1, K)

print("\nComparison (Row 0 is fixed to 0, these are rows 1 to J-1):")
# Header
print("-" * 88)
print(f"{'True':<10} | {'Est':<10} | {'SE':<10} | {'t-stat':<10} | {'p-val':<10} | {'95% CI':<15}")
print(
f"{'True':<10} | {'Est':<10} | {'SE':<10} | {'t-stat':<10} | {'p-val':<10} | {'95% CI':<15}"
)
print("-" * 88)

for i in range(J-1):
print(f"Alternative {i+1}:")
for i in range(J - 1):
print(f"Alternative {i + 1}:")
for k in range(K):
t_val = true_beta[i, k]
e_val = est_beta[i, k]
Expand All @@ -72,7 +72,9 @@ def main():
ci_upper = e_val + 1.96 * se
ci_str = f"[{ci_lower:.2f}, {ci_upper:.2f}]"

print(f" {t_val:<9.4f} | {e_val:<9.4f} | {se:<9.4f} | {t_stat:<9.2f} | {p_val:<9.4f} | {ci_str:<15}")
print(
f" {t_val:<9.4f} | {e_val:<9.4f} | {se:<9.4f} | {t_stat:<9.2f} | {p_val:<9.4f} | {ci_str:<15}"
)
print("-" * 88)

mae = np.mean(np.abs(est_beta - true_beta))
Expand Down
108 changes: 63 additions & 45 deletions examples/benchmark.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@
Tests speed and accuracy across different problem sizes and optimization methods.
"""

import numpy as np
import time

import numpy as np
from scipy.optimize import minimize

from multe import MultichoiceLogit, simulate_data


def benchmark_estimation(N, J, K, mix_ratio=0.5, method='BFGS', seed=42):
def benchmark_estimation(N, J, K, mix_ratio=0.5, method="BFGS", seed=42):
"""
Benchmark a single estimation run.

Expand All @@ -19,12 +21,14 @@ def benchmark_estimation(N, J, K, mix_ratio=0.5, method='BFGS', seed=42):
"""
# Simulate data
t0 = time.time()
X, y_single, y_dual, true_beta = simulate_data(N, J, K, mix_ratio=mix_ratio, seed=seed)
X, y_single, y_dual, true_beta = simulate_data(
N, J, K, mix_ratio=mix_ratio, seed=seed
)
sim_time = time.time() - t0

# Initialize model
model = MultichoiceLogit(J, K)
init_beta = np.zeros((J-1) * K)
init_beta = np.zeros((J - 1) * K)

# Time likelihood evaluation
t0 = time.time()
Expand All @@ -44,59 +48,65 @@ def benchmark_estimation(N, J, K, mix_ratio=0.5, method='BFGS', seed=42):
x0=init_beta,
args=(X, y_single, y_dual),
method=method,
options={'disp': False, 'gtol': 1e-5, 'maxiter': 1000}
options={"disp": False, "gtol": 1e-5, "maxiter": 1000},
)
opt_time = time.time() - t0

# Compute accuracy
est_beta = result.x.reshape(J-1, K)
est_beta = result.x.reshape(J - 1, K)
mae = np.mean(np.abs(est_beta - true_beta))
rmse = np.sqrt(np.mean((est_beta - true_beta)**2))
rmse = np.sqrt(np.mean((est_beta - true_beta) ** 2))
max_error = np.max(np.abs(est_beta - true_beta))

# Compute standard errors
t0 = time.time()
std_errs = model.compute_standard_errors(result.x, X, y_single, y_dual)
model.compute_standard_errors(result.x, X, y_single, y_dual)
se_time = time.time() - t0

return {
'N': N, 'J': J, 'K': K,
'method': method,
'sim_time': sim_time,
'likelihood_time': likelihood_time,
'gradient_time': gradient_time,
'opt_time': opt_time,
'se_time': se_time,
'total_time': sim_time + opt_time + se_time,
'success': result.success,
'nit': result.nit,
'nfev': result.nfev,
'final_nll': result.fun,
'mae': mae,
'rmse': rmse,
'max_error': max_error
"N": N,
"J": J,
"K": K,
"method": method,
"sim_time": sim_time,
"likelihood_time": likelihood_time,
"gradient_time": gradient_time,
"opt_time": opt_time,
"se_time": se_time,
"total_time": sim_time + opt_time + se_time,
"success": result.success,
"nit": result.nit,
"nfev": result.nfev,
"final_nll": result.fun,
"mae": mae,
"rmse": rmse,
"max_error": max_error,
}


def print_results(results):
"""Pretty print benchmark results."""
print("\n" + "="*100)
print(f"{'N':<7} {'J':<4} {'K':<4} {'Method':<10} {'Opt(s)':<8} {'SE(s)':<8} {'Total(s)':<9} "
f"{'Iters':<6} {'FEval':<6} {'MAE':<8} {'RMSE':<8} {'MaxErr':<8} {'Success':<7}")
print("="*100)
print("\n" + "=" * 100)
print(
f"{'N':<7} {'J':<4} {'K':<4} {'Method':<10} {'Opt(s)':<8} {'SE(s)':<8} {'Total(s)':<9} "
f"{'Iters':<6} {'FEval':<6} {'MAE':<8} {'RMSE':<8} {'MaxErr':<8} {'Success':<7}"
)
print("=" * 100)

for r in results:
print(f"{r['N']:<7} {r['J']:<4} {r['K']:<4} {r['method']:<10} "
f"{r['opt_time']:<8.3f} {r['se_time']:<8.3f} {r['total_time']:<9.3f} "
f"{r['nit']:<6} {r['nfev']:<6} "
f"{r['mae']:<8.4f} {r['rmse']:<8.4f} {r['max_error']:<8.4f} "
f"{'✓' if r['success'] else '✗':<7}")
print("="*100)
print(
f"{r['N']:<7} {r['J']:<4} {r['K']:<4} {r['method']:<10} "
f"{r['opt_time']:<8.3f} {r['se_time']:<8.3f} {r['total_time']:<9.3f} "
f"{r['nit']:<6} {r['nfev']:<6} "
f"{r['mae']:<8.4f} {r['rmse']:<8.4f} {r['max_error']:<8.4f} "
f"{'✓' if r['success'] else '✗':<7}"
)
print("=" * 100)


def main():
print("Multichoice Logit Estimation Benchmark")
print("="*100)
print("=" * 100)

# Test different problem sizes
problem_sizes = [
Expand All @@ -108,7 +118,7 @@ def main():
]

# Test different optimization methods
methods = ['BFGS', 'L-BFGS-B', 'Newton-CG']
methods = ["BFGS", "L-BFGS-B", "Newton-CG"]

results = []

Expand All @@ -117,7 +127,7 @@ def main():
print("-" * 100)
for N, J, K in problem_sizes:
print(f"Running: N={N}, J={J}, K={K}...", end=" ", flush=True)
r = benchmark_estimation(N, J, K, method='BFGS')
r = benchmark_estimation(N, J, K, method="BFGS")
results.append(r)
print(f"✓ ({r['opt_time']:.2f}s)")

Expand All @@ -139,8 +149,8 @@ def main():
print("-" * 100)
for mix_ratio in [0.2, 0.5, 0.8]:
print(f"Running: mix_ratio={mix_ratio}...", end=" ", flush=True)
r = benchmark_estimation(2000, 4, 3, mix_ratio=mix_ratio, method='BFGS')
r['mix_ratio'] = mix_ratio
r = benchmark_estimation(2000, 4, 3, mix_ratio=mix_ratio, method="BFGS")
r["mix_ratio"] = mix_ratio
results.append(r)
print(f"✓ ({r['opt_time']:.2f}s)")

Expand All @@ -150,16 +160,24 @@ def main():
# Summary statistics
print("\nSummary:")
print("-" * 100)
bfgs_results = [r for r in results if r['method'] == 'BFGS' and 'mix_ratio' not in r]
bfgs_results = [
r for r in results if r["method"] == "BFGS" and "mix_ratio" not in r
]
if bfgs_results:
avg_time_per_1k = np.mean([r['opt_time'] / (r['N']/1000) for r in bfgs_results])
avg_mae = np.mean([r['mae'] for r in bfgs_results])
avg_rmse = np.mean([r['rmse'] for r in bfgs_results])

print(f"Average optimization time per 1000 observations: {avg_time_per_1k:.3f}s")
avg_time_per_1k = np.mean(
[r["opt_time"] / (r["N"] / 1000) for r in bfgs_results]
)
avg_mae = np.mean([r["mae"] for r in bfgs_results])
avg_rmse = np.mean([r["rmse"] for r in bfgs_results])

print(
f"Average optimization time per 1000 observations: {avg_time_per_1k:.3f}s"
)
print(f"Average MAE: {avg_mae:.4f}")
print(f"Average RMSE: {avg_rmse:.4f}")
print(f"Success rate: {sum(r['success'] for r in bfgs_results) / len(bfgs_results) * 100:.1f}%")
print(
f"Success rate: {sum(r['success'] for r in bfgs_results) / len(bfgs_results) * 100:.1f}%"
)


if __name__ == "__main__":
Expand Down
Loading