Spaces:
Sleeping
Sleeping
Add GMNL variants, IP tracking, BWS fix, session activity, UI polish
Browse files- GMNL special forms dropdown: S-MNL (gamma=0), GMNL-II (gamma=1)
- Admin IP tracking with country/region geolocation and VPN detection
- BWS early validation (J>=3) and clean error display (no traceback)
- Session timeout: JS activity monitor (scroll/mouse/keyboard) keeps heartbeat alive
- Auth: replace real name example with fictional "Jordan McAllister"
- Home: remove duplicate WeChat donation pill (keep expander)
- Admin page moved to top-right "⋯" Developer Entry menu (admin only)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- app/Home.py +2 -10
- app/auth.py +2 -2
- app/community_db.py +103 -4
- app/pages/2_⚙️_Model.py +48 -6
- app/pages/8_🔒_Admin.py +50 -2
- app/utils.py +128 -0
- src/dce_analyzer/config.py +3 -0
- src/dce_analyzer/model.py +36 -13
- src/dce_analyzer/pipeline.py +3 -0
app/Home.py
CHANGED
|
@@ -174,18 +174,10 @@ with _bro_col2:
|
|
| 174 |
)
|
| 175 |
|
| 176 |
st.markdown(
|
| 177 |
-
"<div style='display:flex; gap:12px;'>"
|
| 178 |
-
# ── Buy Me a Coffee ──
|
| 179 |
f"<a href='{_coffee_url}' target='_blank' style='"
|
| 180 |
-
f"{_btn_style}
|
| 181 |
f"<img src='{_bmc_logo}' width='22' style='flex-shrink:0;'/>"
|
| 182 |
-
f"Buy Slowbro a Coffee</a>"
|
| 183 |
-
# ── WeChat ──
|
| 184 |
-
f"<div style='{_btn_style} flex:1; background:#07C160; color:#fff;' "
|
| 185 |
-
f"id='wechat-tip-btn'>"
|
| 186 |
-
f"<img src='{_wechat_logo}' width='22' style='flex-shrink:0;'/>"
|
| 187 |
-
f"微信打赏</div>"
|
| 188 |
-
"</div>",
|
| 189 |
unsafe_allow_html=True,
|
| 190 |
)
|
| 191 |
|
|
|
|
| 174 |
)
|
| 175 |
|
| 176 |
st.markdown(
|
|
|
|
|
|
|
| 177 |
f"<a href='{_coffee_url}' target='_blank' style='"
|
| 178 |
+
f"{_btn_style} max-width:300px; background:#FFDD00; color:#000;'>"
|
| 179 |
f"<img src='{_bmc_logo}' width='22' style='flex-shrink:0;'/>"
|
| 180 |
+
f"Buy Slowbro a Coffee</a>",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
unsafe_allow_html=True,
|
| 182 |
)
|
| 183 |
|
app/auth.py
CHANGED
|
@@ -7,7 +7,7 @@ Supports two methods:
|
|
| 7 |
MUCHE team members are stored as a whitelist.
|
| 8 |
First login: match name + set password to claim account.
|
| 9 |
Subsequent logins: match name + enter password.
|
| 10 |
-
Fuzzy matching allows minor name variations (e.g. "
|
| 11 |
|
| 12 |
Toggle with PREFERO_AUTH_ENABLED env var ("true" to enable).
|
| 13 |
"""
|
|
@@ -213,7 +213,7 @@ def auth_gate() -> bool:
|
|
| 213 |
name_input = st.text_input(
|
| 214 |
"Your name",
|
| 215 |
key="_auth_member_name",
|
| 216 |
-
placeholder="e.g.
|
| 217 |
)
|
| 218 |
|
| 219 |
matched_name = None
|
|
|
|
| 7 |
MUCHE team members are stored as a whitelist.
|
| 8 |
First login: match name + set password to claim account.
|
| 9 |
Subsequent logins: match name + enter password.
|
| 10 |
+
Fuzzy matching allows minor name variations (e.g. "John" matches "Jonathan").
|
| 11 |
|
| 12 |
Toggle with PREFERO_AUTH_ENABLED env var ("true" to enable).
|
| 13 |
"""
|
|
|
|
| 213 |
name_input = st.text_input(
|
| 214 |
"Your name",
|
| 215 |
key="_auth_member_name",
|
| 216 |
+
placeholder="e.g. Jordan McAllister",
|
| 217 |
)
|
| 218 |
|
| 219 |
matched_name = None
|
app/community_db.py
CHANGED
|
@@ -17,10 +17,82 @@ import sqlite3
|
|
| 17 |
import threading
|
| 18 |
import time
|
| 19 |
from dataclasses import dataclass
|
|
|
|
| 20 |
from pathlib import Path
|
| 21 |
|
| 22 |
logger = logging.getLogger(__name__)
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
# ---------------------------------------------------------------------------
|
| 25 |
# HF Dataset persistence for user accounts
|
| 26 |
# ---------------------------------------------------------------------------
|
|
@@ -200,6 +272,24 @@ def init_db() -> None:
|
|
| 200 |
CREATE INDEX IF NOT EXISTS idx_activity_time ON activity_log(timestamp DESC);
|
| 201 |
""")
|
| 202 |
conn.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
# Load persisted users from HF on first startup
|
| 204 |
_sync_users_from_hf()
|
| 205 |
|
|
@@ -277,13 +367,22 @@ def create_user(username: str, email: str = "") -> User:
|
|
| 277 |
|
| 278 |
|
| 279 |
def log_activity(username: str, action: str, details: str = "") -> None:
|
| 280 |
-
"""Record an activity event."""
|
| 281 |
try:
|
| 282 |
conn = _get_conn()
|
|
|
|
|
|
|
| 283 |
conn.execute(
|
| 284 |
-
"INSERT INTO activity_log
|
| 285 |
-
"
|
| 286 |
-
(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 287 |
)
|
| 288 |
conn.commit()
|
| 289 |
except Exception as exc:
|
|
|
|
| 17 |
import threading
|
| 18 |
import time
|
| 19 |
from dataclasses import dataclass
|
| 20 |
+
from functools import lru_cache
|
| 21 |
from pathlib import Path
|
| 22 |
|
| 23 |
logger = logging.getLogger(__name__)
|
| 24 |
|
| 25 |
+
|
| 26 |
+
# ---------------------------------------------------------------------------
|
| 27 |
+
# IP geolocation helpers
|
| 28 |
+
# ---------------------------------------------------------------------------
|
| 29 |
+
|
| 30 |
+
# In-memory cache: ip -> {country, region, city, org, is_vpn}
|
| 31 |
+
_geo_cache: dict[str, dict] = {}
|
| 32 |
+
_GEO_CACHE_MAX = 500
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def get_client_ip() -> str | None:
|
| 36 |
+
"""Extract client IP from Streamlit request headers (X-Forwarded-For)."""
|
| 37 |
+
try:
|
| 38 |
+
import streamlit as st
|
| 39 |
+
xff = st.context.headers.get("x-forwarded-for")
|
| 40 |
+
if xff and isinstance(xff, str):
|
| 41 |
+
return xff.split(",")[0].strip()
|
| 42 |
+
return None
|
| 43 |
+
except Exception:
|
| 44 |
+
return None
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def geolocate_ip(ip: str | None) -> dict:
|
| 48 |
+
"""Look up country, region, org for an IP via ipapi.co (free, 30k/month).
|
| 49 |
+
|
| 50 |
+
Returns dict with keys: country, region, city, org, is_vpn.
|
| 51 |
+
Results are cached in-memory.
|
| 52 |
+
"""
|
| 53 |
+
if not ip:
|
| 54 |
+
return {}
|
| 55 |
+
if ip in _geo_cache:
|
| 56 |
+
return _geo_cache[ip]
|
| 57 |
+
|
| 58 |
+
result: dict = {}
|
| 59 |
+
try:
|
| 60 |
+
import requests
|
| 61 |
+
resp = requests.get(
|
| 62 |
+
f"https://ipapi.co/{ip}/json/",
|
| 63 |
+
timeout=3,
|
| 64 |
+
headers={"User-Agent": "Prefero/1.0"},
|
| 65 |
+
)
|
| 66 |
+
if resp.status_code == 200:
|
| 67 |
+
data = resp.json()
|
| 68 |
+
if not data.get("error"):
|
| 69 |
+
org = data.get("org", "")
|
| 70 |
+
# Heuristic VPN/datacenter detection: check org name
|
| 71 |
+
_vpn_keywords = [
|
| 72 |
+
"vpn", "proxy", "tunnel", "hosting", "datacenter",
|
| 73 |
+
"data center", "cloud", "server", "digitalocean",
|
| 74 |
+
"amazon", "aws", "google cloud", "microsoft azure",
|
| 75 |
+
"linode", "vultr", "ovh", "hetzner", "mullvad",
|
| 76 |
+
"nordvpn", "expressvpn", "surfshark", "proton",
|
| 77 |
+
]
|
| 78 |
+
is_vpn = any(kw in org.lower() for kw in _vpn_keywords)
|
| 79 |
+
result = {
|
| 80 |
+
"country": data.get("country_name", ""),
|
| 81 |
+
"country_code": data.get("country_code", ""),
|
| 82 |
+
"region": data.get("region", ""),
|
| 83 |
+
"city": data.get("city", ""),
|
| 84 |
+
"org": org,
|
| 85 |
+
"is_vpn": is_vpn,
|
| 86 |
+
}
|
| 87 |
+
except Exception as exc:
|
| 88 |
+
logger.debug("Geo lookup failed for %s: %s", ip, exc)
|
| 89 |
+
|
| 90 |
+
# Cache result (even empty, to avoid repeated failed lookups)
|
| 91 |
+
if len(_geo_cache) < _GEO_CACHE_MAX:
|
| 92 |
+
_geo_cache[ip] = result
|
| 93 |
+
return result
|
| 94 |
+
|
| 95 |
+
|
| 96 |
# ---------------------------------------------------------------------------
|
| 97 |
# HF Dataset persistence for user accounts
|
| 98 |
# ---------------------------------------------------------------------------
|
|
|
|
| 272 |
CREATE INDEX IF NOT EXISTS idx_activity_time ON activity_log(timestamp DESC);
|
| 273 |
""")
|
| 274 |
conn.commit()
|
| 275 |
+
# Schema migration: add IP/geo columns to activity_log if missing
|
| 276 |
+
try:
|
| 277 |
+
conn.execute("ALTER TABLE activity_log ADD COLUMN ip_address TEXT DEFAULT ''")
|
| 278 |
+
except sqlite3.OperationalError:
|
| 279 |
+
pass # column already exists
|
| 280 |
+
try:
|
| 281 |
+
conn.execute("ALTER TABLE activity_log ADD COLUMN country TEXT DEFAULT ''")
|
| 282 |
+
except sqlite3.OperationalError:
|
| 283 |
+
pass
|
| 284 |
+
try:
|
| 285 |
+
conn.execute("ALTER TABLE activity_log ADD COLUMN region TEXT DEFAULT ''")
|
| 286 |
+
except sqlite3.OperationalError:
|
| 287 |
+
pass
|
| 288 |
+
try:
|
| 289 |
+
conn.execute("ALTER TABLE activity_log ADD COLUMN is_vpn INTEGER DEFAULT 0")
|
| 290 |
+
except sqlite3.OperationalError:
|
| 291 |
+
pass
|
| 292 |
+
conn.commit()
|
| 293 |
# Load persisted users from HF on first startup
|
| 294 |
_sync_users_from_hf()
|
| 295 |
|
|
|
|
| 367 |
|
| 368 |
|
| 369 |
def log_activity(username: str, action: str, details: str = "") -> None:
|
| 370 |
+
"""Record an activity event with IP geolocation."""
|
| 371 |
try:
|
| 372 |
conn = _get_conn()
|
| 373 |
+
ip = get_client_ip()
|
| 374 |
+
geo = geolocate_ip(ip) if ip else {}
|
| 375 |
conn.execute(
|
| 376 |
+
"INSERT INTO activity_log "
|
| 377 |
+
"(timestamp, username, action, details, ip_address, country, region, is_vpn) "
|
| 378 |
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
| 379 |
+
(
|
| 380 |
+
time.time(), username, action, details,
|
| 381 |
+
ip or "",
|
| 382 |
+
geo.get("country", ""),
|
| 383 |
+
geo.get("region", ""),
|
| 384 |
+
1 if geo.get("is_vpn") else 0,
|
| 385 |
+
),
|
| 386 |
)
|
| 387 |
conn.commit()
|
| 388 |
except Exception as exc:
|
app/pages/2_⚙️_Model.py
CHANGED
|
@@ -333,6 +333,25 @@ elif model_type == "gmnl":
|
|
| 333 |
"Nests both S-MNL (pure scale) and MMNL as special cases."
|
| 334 |
)
|
| 335 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 336 |
st.markdown("**Distribution assumptions**")
|
| 337 |
st.caption("Set distributions for each variable. At least one random variable is required.")
|
| 338 |
with st.expander("What do the distribution options mean?"):
|
|
@@ -369,11 +388,13 @@ elif model_type == "gmnl":
|
|
| 369 |
|
| 370 |
with st.expander("About GMNL scale parameters"):
|
| 371 |
st.markdown(
|
| 372 |
-
"The GMNL model estimates
|
| 373 |
"- **tau** (scale mean): controls the average scale of utility.\n"
|
| 374 |
"- **sigma_tau** (scale SD): individual variation in scale.\n"
|
| 375 |
"- **gamma** (mixing, 0-1): gamma=0 is pure scale heterogeneity (S-MNL), "
|
| 376 |
-
"gamma=1 is GMNL-II (closest to standard MMNL)."
|
|
|
|
|
|
|
| 377 |
)
|
| 378 |
|
| 379 |
# ── Correlation structure for GMNL ────────────────────────────────
|
|
@@ -577,6 +598,8 @@ else: # latent_class
|
|
| 577 |
if model_type not in ("mixed", "gmnl"):
|
| 578 |
mxl_correlated = False
|
| 579 |
mxl_correlation_groups = None
|
|
|
|
|
|
|
| 580 |
if model_type != "latent_class":
|
| 581 |
lc_membership_cols = []
|
| 582 |
lc_method = "em"
|
|
@@ -939,6 +962,9 @@ def _suggest_model_name() -> str:
|
|
| 939 |
"""Build a descriptive default name from the current model configuration."""
|
| 940 |
prefix_map = {"conditional": "CL", "mixed": "MXL", "gmnl": "GMNL", "latent_class": "LC"}
|
| 941 |
prefix = prefix_map[model_type]
|
|
|
|
|
|
|
|
|
|
| 942 |
parts: list[str] = [prefix]
|
| 943 |
|
| 944 |
if model_type in ("mixed", "gmnl"):
|
|
@@ -1071,9 +1097,10 @@ def _build_param_names() -> list[dict[str, str]]:
|
|
| 1071 |
rows.append({"#": str(idx), "Parameter": "raw_sigma_tau",
|
| 1072 |
"Description": "Scale SD (raw, softplus-transformed)"})
|
| 1073 |
idx += 1
|
| 1074 |
-
|
| 1075 |
-
|
| 1076 |
-
|
|
|
|
| 1077 |
|
| 1078 |
elif model_type == "latent_class":
|
| 1079 |
for q in range(1, n_classes + 1):
|
|
@@ -1188,6 +1215,7 @@ if st.button("Run Estimation", type="primary", use_container_width=True):
|
|
| 1188 |
interactions=_het_interactions,
|
| 1189 |
correlated=mxl_correlated,
|
| 1190 |
correlation_groups=mxl_correlation_groups,
|
|
|
|
| 1191 |
bws_worst_col=bws_worst_col if bws_mode else None,
|
| 1192 |
estimate_lambda_w=bws_estimate_lambda_w if bws_mode else True,
|
| 1193 |
n_classes=int(n_classes),
|
|
@@ -1200,6 +1228,16 @@ if st.button("Run Estimation", type="primary", use_container_width=True):
|
|
| 1200 |
custom_start=_parsed_custom_start,
|
| 1201 |
)
|
| 1202 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1203 |
# Pass the original DataFrame; backend handles dummy expansion
|
| 1204 |
est_df = df
|
| 1205 |
|
|
@@ -1273,10 +1311,14 @@ if st.button("Run Estimation", type="primary", use_container_width=True):
|
|
| 1273 |
st.error(f"Estimation failed: {exc}")
|
| 1274 |
try:
|
| 1275 |
from community_db import log_activity
|
|
|
|
|
|
|
| 1276 |
log_activity(st.session_state.get("username", "anonymous"), "estimation_error", f"model={model_type}, error={str(exc)[:200]}")
|
|
|
|
|
|
|
|
|
|
| 1277 |
except Exception:
|
| 1278 |
pass
|
| 1279 |
-
st.exception(exc)
|
| 1280 |
st.stop()
|
| 1281 |
|
| 1282 |
st.session_state["_estimation_running"] = False
|
|
|
|
| 333 |
"Nests both S-MNL (pure scale) and MMNL as special cases."
|
| 334 |
)
|
| 335 |
|
| 336 |
+
gmnl_variant_label = st.selectbox(
|
| 337 |
+
"GMNL variant",
|
| 338 |
+
["GMNL (General)", "S-MNL (gamma=0)", "GMNL-II (gamma=1)"],
|
| 339 |
+
key="gmnl_variant_select",
|
| 340 |
+
help=(
|
| 341 |
+
"**GMNL (General):** gamma is freely estimated (Fiebig et al. 2010). "
|
| 342 |
+
"**S-MNL:** gamma fixed at 0 — pure scale heterogeneity only; "
|
| 343 |
+
"all taste variation comes from individual scale differences. "
|
| 344 |
+
"**GMNL-II:** gamma fixed at 1 — scale multiplies the full "
|
| 345 |
+
"individual-specific beta (mean + deviation)."
|
| 346 |
+
),
|
| 347 |
+
)
|
| 348 |
+
_gmnl_variant_map = {
|
| 349 |
+
"GMNL (General)": "general",
|
| 350 |
+
"S-MNL (gamma=0)": "s-mnl",
|
| 351 |
+
"GMNL-II (gamma=1)": "gmnl-ii",
|
| 352 |
+
}
|
| 353 |
+
gmnl_variant = _gmnl_variant_map[gmnl_variant_label]
|
| 354 |
+
|
| 355 |
st.markdown("**Distribution assumptions**")
|
| 356 |
st.caption("Set distributions for each variable. At least one random variable is required.")
|
| 357 |
with st.expander("What do the distribution options mean?"):
|
|
|
|
| 388 |
|
| 389 |
with st.expander("About GMNL scale parameters"):
|
| 390 |
st.markdown(
|
| 391 |
+
"The GMNL model estimates additional scale parameters:\n"
|
| 392 |
"- **tau** (scale mean): controls the average scale of utility.\n"
|
| 393 |
"- **sigma_tau** (scale SD): individual variation in scale.\n"
|
| 394 |
"- **gamma** (mixing, 0-1): gamma=0 is pure scale heterogeneity (S-MNL), "
|
| 395 |
+
"gamma=1 is GMNL-II (closest to standard MMNL).\n\n"
|
| 396 |
+
"When using **S-MNL** or **GMNL-II**, gamma is fixed and not estimated — "
|
| 397 |
+
"reducing the parameter count by 1."
|
| 398 |
)
|
| 399 |
|
| 400 |
# ── Correlation structure for GMNL ────────────────────────────────
|
|
|
|
| 598 |
if model_type not in ("mixed", "gmnl"):
|
| 599 |
mxl_correlated = False
|
| 600 |
mxl_correlation_groups = None
|
| 601 |
+
if model_type != "gmnl":
|
| 602 |
+
gmnl_variant = "general"
|
| 603 |
if model_type != "latent_class":
|
| 604 |
lc_membership_cols = []
|
| 605 |
lc_method = "em"
|
|
|
|
| 962 |
"""Build a descriptive default name from the current model configuration."""
|
| 963 |
prefix_map = {"conditional": "CL", "mixed": "MXL", "gmnl": "GMNL", "latent_class": "LC"}
|
| 964 |
prefix = prefix_map[model_type]
|
| 965 |
+
if model_type == "gmnl" and gmnl_variant != "general":
|
| 966 |
+
variant_labels = {"s-mnl": "S-MNL", "gmnl-ii": "GMNL-II"}
|
| 967 |
+
prefix = variant_labels.get(gmnl_variant, prefix)
|
| 968 |
parts: list[str] = [prefix]
|
| 969 |
|
| 970 |
if model_type in ("mixed", "gmnl"):
|
|
|
|
| 1097 |
rows.append({"#": str(idx), "Parameter": "raw_sigma_tau",
|
| 1098 |
"Description": "Scale SD (raw, softplus-transformed)"})
|
| 1099 |
idx += 1
|
| 1100 |
+
if gmnl_variant == "general":
|
| 1101 |
+
rows.append({"#": str(idx), "Parameter": "raw_gamma",
|
| 1102 |
+
"Description": "Mixing parameter (raw, sigmoid -> [0,1])"})
|
| 1103 |
+
idx += 1
|
| 1104 |
|
| 1105 |
elif model_type == "latent_class":
|
| 1106 |
for q in range(1, n_classes + 1):
|
|
|
|
| 1215 |
interactions=_het_interactions,
|
| 1216 |
correlated=mxl_correlated,
|
| 1217 |
correlation_groups=mxl_correlation_groups,
|
| 1218 |
+
gmnl_variant=gmnl_variant,
|
| 1219 |
bws_worst_col=bws_worst_col if bws_mode else None,
|
| 1220 |
estimate_lambda_w=bws_estimate_lambda_w if bws_mode else True,
|
| 1221 |
n_classes=int(n_classes),
|
|
|
|
| 1228 |
custom_start=_parsed_custom_start,
|
| 1229 |
)
|
| 1230 |
|
| 1231 |
+
# ── Early validation: BWS requires J >= 3 ──────────────────
|
| 1232 |
+
if bws_mode:
|
| 1233 |
+
_n_alts_per_task = df.groupby([id_col, task_col]).size()
|
| 1234 |
+
if _n_alts_per_task.min() < 3:
|
| 1235 |
+
st.error(
|
| 1236 |
+
"BWS requires at least 3 alternatives per task (J >= 3). "
|
| 1237 |
+
f"Your data has tasks with only {_n_alts_per_task.min()} alternatives."
|
| 1238 |
+
)
|
| 1239 |
+
st.stop()
|
| 1240 |
+
|
| 1241 |
# Pass the original DataFrame; backend handles dummy expansion
|
| 1242 |
est_df = df
|
| 1243 |
|
|
|
|
| 1311 |
st.error(f"Estimation failed: {exc}")
|
| 1312 |
try:
|
| 1313 |
from community_db import log_activity
|
| 1314 |
+
import traceback as _tb
|
| 1315 |
+
_tb_str = _tb.format_exc()
|
| 1316 |
log_activity(st.session_state.get("username", "anonymous"), "estimation_error", f"model={model_type}, error={str(exc)[:200]}")
|
| 1317 |
+
logger_msg = f"Estimation error: {_tb_str}"
|
| 1318 |
+
import logging
|
| 1319 |
+
logging.getLogger("prefero").warning(logger_msg)
|
| 1320 |
except Exception:
|
| 1321 |
pass
|
|
|
|
| 1322 |
st.stop()
|
| 1323 |
|
| 1324 |
st.session_state["_estimation_running"] = False
|
app/pages/8_🔒_Admin.py
CHANGED
|
@@ -223,12 +223,22 @@ try:
|
|
| 223 |
log_data = []
|
| 224 |
for r in log_rows:
|
| 225 |
ts = datetime.fromtimestamp(r["timestamp"], tz=timezone.utc)
|
| 226 |
-
|
| 227 |
"Timestamp": ts.strftime("%Y-%m-%d %H:%M:%S UTC"),
|
| 228 |
"Username": r["username"],
|
| 229 |
"Action": r["action"],
|
| 230 |
"Details": r["details"],
|
| 231 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
log_df = pd.DataFrame(log_data)
|
| 233 |
st.dataframe(log_df, use_container_width=True, hide_index=True)
|
| 234 |
|
|
@@ -244,6 +254,44 @@ try:
|
|
| 244 |
except Exception as _exc:
|
| 245 |
st.info(f"Activity timeline not available: {_exc}")
|
| 246 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
# ── 2b. Model Usage Stats ───────────────────────────────────────
|
| 248 |
|
| 249 |
st.header("Model Usage Stats")
|
|
|
|
| 223 |
log_data = []
|
| 224 |
for r in log_rows:
|
| 225 |
ts = datetime.fromtimestamp(r["timestamp"], tz=timezone.utc)
|
| 226 |
+
row = {
|
| 227 |
"Timestamp": ts.strftime("%Y-%m-%d %H:%M:%S UTC"),
|
| 228 |
"Username": r["username"],
|
| 229 |
"Action": r["action"],
|
| 230 |
"Details": r["details"],
|
| 231 |
+
}
|
| 232 |
+
# IP/geo columns (may not exist in older records)
|
| 233 |
+
ip_val = r["ip_address"] if "ip_address" in r.keys() else ""
|
| 234 |
+
country_val = r["country"] if "country" in r.keys() else ""
|
| 235 |
+
region_val = r["region"] if "region" in r.keys() else ""
|
| 236 |
+
vpn_val = r["is_vpn"] if "is_vpn" in r.keys() else 0
|
| 237 |
+
row["IP"] = ip_val or ""
|
| 238 |
+
row["Country"] = country_val or ""
|
| 239 |
+
row["Region"] = region_val or ""
|
| 240 |
+
row["VPN"] = "Yes" if vpn_val else ""
|
| 241 |
+
log_data.append(row)
|
| 242 |
log_df = pd.DataFrame(log_data)
|
| 243 |
st.dataframe(log_df, use_container_width=True, hide_index=True)
|
| 244 |
|
|
|
|
| 254 |
except Exception as _exc:
|
| 255 |
st.info(f"Activity timeline not available: {_exc}")
|
| 256 |
|
| 257 |
+
# ── 2a-2. Geographic Overview ──────────────────────────────────
|
| 258 |
+
|
| 259 |
+
st.header("Geographic Overview")
|
| 260 |
+
|
| 261 |
+
try:
|
| 262 |
+
geo_rows = conn.execute(
|
| 263 |
+
"SELECT country, region, ip_address, is_vpn, COUNT(*) as cnt "
|
| 264 |
+
"FROM activity_log WHERE country != '' GROUP BY country ORDER BY cnt DESC"
|
| 265 |
+
).fetchall()
|
| 266 |
+
if geo_rows:
|
| 267 |
+
geo_data = [
|
| 268 |
+
{"Country": r["country"], "Activities": r["cnt"]}
|
| 269 |
+
for r in geo_rows
|
| 270 |
+
]
|
| 271 |
+
geo_df = pd.DataFrame(geo_data)
|
| 272 |
+
g1, g2 = st.columns(2)
|
| 273 |
+
with g1:
|
| 274 |
+
st.dataframe(geo_df, use_container_width=True, hide_index=True)
|
| 275 |
+
with g2:
|
| 276 |
+
if _has_plotly:
|
| 277 |
+
fig_geo = px.pie(geo_df, names="Country", values="Activities", title="Activities by Country")
|
| 278 |
+
st.plotly_chart(fig_geo, use_container_width=True, key="admin_geo_pie")
|
| 279 |
+
|
| 280 |
+
vpn_total = conn.execute(
|
| 281 |
+
"SELECT SUM(is_vpn) as vpn_cnt, COUNT(*) as total "
|
| 282 |
+
"FROM activity_log WHERE ip_address != ''"
|
| 283 |
+
).fetchone()
|
| 284 |
+
if vpn_total and vpn_total["total"] > 0:
|
| 285 |
+
vpn_count = int(vpn_total["vpn_cnt"] or 0)
|
| 286 |
+
total_with_ip = int(vpn_total["total"])
|
| 287 |
+
v1, v2 = st.columns(2)
|
| 288 |
+
v1.metric("Tracked requests (with IP)", total_with_ip)
|
| 289 |
+
v2.metric("Suspected VPN/Datacenter", f"{vpn_count} ({vpn_count/total_with_ip*100:.1f}%)" if total_with_ip else "0")
|
| 290 |
+
else:
|
| 291 |
+
st.info("No geographic data yet. IP tracking starts collecting data on the next user activity.")
|
| 292 |
+
except Exception as _exc:
|
| 293 |
+
st.info(f"Geographic data not available: {_exc}")
|
| 294 |
+
|
| 295 |
# ── 2b. Model Usage Stats ───────────────────────────────────────
|
| 296 |
|
| 297 |
st.header("Model Usage Stats")
|
app/utils.py
CHANGED
|
@@ -87,6 +87,47 @@ def _check_session_timeout() -> None:
|
|
| 87 |
st.stop()
|
| 88 |
|
| 89 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
def init_session_state() -> None:
|
| 91 |
"""Ensure every shared session-state key exists with its default value."""
|
| 92 |
for key, default in _SESSION_DEFAULTS.items():
|
|
@@ -99,6 +140,7 @@ def init_session_state() -> None:
|
|
| 99 |
require_queue_slot()
|
| 100 |
queue_heartbeat()
|
| 101 |
st.session_state["_queue_admitted"] = True
|
|
|
|
| 102 |
import inspect
|
| 103 |
_caller = inspect.stack()[1].filename
|
| 104 |
_page = Path(_caller).stem
|
|
@@ -118,12 +160,96 @@ def data_is_loaded() -> bool:
|
|
| 118 |
return st.session_state.get("df") is not None
|
| 119 |
|
| 120 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
def slowbro_status() -> None:
|
| 122 |
"""Inject global Slowbro status widget into Streamlit's fixed header."""
|
| 123 |
_slowbro_img = (
|
| 124 |
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites"
|
| 125 |
"/pokemon/other/official-artwork/80.png"
|
| 126 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
st.markdown(
|
| 128 |
f"""
|
| 129 |
<style>
|
|
@@ -150,7 +276,9 @@ def slowbro_status() -> None:
|
|
| 150 |
white-space: nowrap;
|
| 151 |
z-index: 999;
|
| 152 |
}}
|
|
|
|
| 153 |
</style>
|
|
|
|
| 154 |
""",
|
| 155 |
unsafe_allow_html=True,
|
| 156 |
)
|
|
|
|
| 87 |
st.stop()
|
| 88 |
|
| 89 |
|
| 90 |
+
def _inject_activity_heartbeat() -> None:
|
| 91 |
+
"""Inject JavaScript that detects user activity and auto-refreshes to keep the session alive.
|
| 92 |
+
|
| 93 |
+
Monitors scroll, mouse movement, keyboard, and touch events on the parent
|
| 94 |
+
document. If activity is detected within the last 90 seconds, triggers a
|
| 95 |
+
lightweight page reload every 90 seconds. Streamlit session state persists
|
| 96 |
+
across reloads, so the heartbeat() call in init_session_state() naturally
|
| 97 |
+
refreshes the server-side timestamp.
|
| 98 |
+
"""
|
| 99 |
+
import os
|
| 100 |
+
if os.environ.get("PREFERO_QUEUE_ENABLED", "").lower() != "true":
|
| 101 |
+
return # no queue → no need for activity tracking
|
| 102 |
+
|
| 103 |
+
import streamlit.components.v1 as _components
|
| 104 |
+
_components.html(
|
| 105 |
+
"""
|
| 106 |
+
<script>
|
| 107 |
+
(function() {
|
| 108 |
+
var INTERVAL = 90000; // 90 seconds
|
| 109 |
+
var lastActivity = Date.now();
|
| 110 |
+
var events = ['scroll', 'mousemove', 'keydown', 'touchstart', 'click', 'wheel'];
|
| 111 |
+
var doc;
|
| 112 |
+
try { doc = window.parent.document; } catch(e) { return; }
|
| 113 |
+
|
| 114 |
+
events.forEach(function(e) {
|
| 115 |
+
doc.addEventListener(e, function() { lastActivity = Date.now(); }, {passive: true});
|
| 116 |
+
});
|
| 117 |
+
|
| 118 |
+
setInterval(function() {
|
| 119 |
+
if (Date.now() - lastActivity < INTERVAL) {
|
| 120 |
+
// User was active — trigger Streamlit rerun to refresh heartbeat
|
| 121 |
+
try { window.parent.location.reload(); } catch(e) {}
|
| 122 |
+
}
|
| 123 |
+
}, INTERVAL);
|
| 124 |
+
})();
|
| 125 |
+
</script>
|
| 126 |
+
""",
|
| 127 |
+
height=0,
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
|
| 131 |
def init_session_state() -> None:
|
| 132 |
"""Ensure every shared session-state key exists with its default value."""
|
| 133 |
for key, default in _SESSION_DEFAULTS.items():
|
|
|
|
| 140 |
require_queue_slot()
|
| 141 |
queue_heartbeat()
|
| 142 |
st.session_state["_queue_admitted"] = True
|
| 143 |
+
_inject_activity_heartbeat()
|
| 144 |
import inspect
|
| 145 |
_caller = inspect.stack()[1].filename
|
| 146 |
_page = Path(_caller).stem
|
|
|
|
| 160 |
return st.session_state.get("df") is not None
|
| 161 |
|
| 162 |
|
| 163 |
+
def _is_admin_user() -> bool:
|
| 164 |
+
"""Check if the current user has admin privileges."""
|
| 165 |
+
import os
|
| 166 |
+
admin_csv = os.environ.get("PREFERO_ADMIN_USERS", "")
|
| 167 |
+
if not admin_csv.strip():
|
| 168 |
+
return False
|
| 169 |
+
admin_users = {u.strip().lower() for u in admin_csv.split(",") if u.strip()}
|
| 170 |
+
current = st.session_state.get("username", "").strip().lower()
|
| 171 |
+
return current in admin_users
|
| 172 |
+
|
| 173 |
+
|
| 174 |
def slowbro_status() -> None:
|
| 175 |
"""Inject global Slowbro status widget into Streamlit's fixed header."""
|
| 176 |
_slowbro_img = (
|
| 177 |
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites"
|
| 178 |
"/pokemon/other/official-artwork/80.png"
|
| 179 |
)
|
| 180 |
+
|
| 181 |
+
# Build optional developer entry menu (admin only)
|
| 182 |
+
_dev_menu_css = ""
|
| 183 |
+
_dev_menu_html = ""
|
| 184 |
+
if _is_admin_user():
|
| 185 |
+
_dev_menu_css = """
|
| 186 |
+
/* Hide Admin page from sidebar nav */
|
| 187 |
+
[data-testid="stSidebarNav"] a[href*="Admin"] {
|
| 188 |
+
display: none !important;
|
| 189 |
+
}
|
| 190 |
+
/* Developer entry ⋯ menu */
|
| 191 |
+
.dev-entry {
|
| 192 |
+
position: fixed;
|
| 193 |
+
top: 10px;
|
| 194 |
+
right: 14px;
|
| 195 |
+
z-index: 1001;
|
| 196 |
+
}
|
| 197 |
+
.dev-entry-btn {
|
| 198 |
+
background: rgba(50,50,50,0.7);
|
| 199 |
+
border: none;
|
| 200 |
+
color: #999;
|
| 201 |
+
font-size: 16px;
|
| 202 |
+
cursor: pointer;
|
| 203 |
+
border-radius: 6px;
|
| 204 |
+
width: 30px;
|
| 205 |
+
height: 30px;
|
| 206 |
+
display: flex;
|
| 207 |
+
align-items: center;
|
| 208 |
+
justify-content: center;
|
| 209 |
+
transition: background 0.15s;
|
| 210 |
+
}
|
| 211 |
+
.dev-entry-btn:hover {
|
| 212 |
+
background: rgba(80,80,80,0.9);
|
| 213 |
+
color: #ddd;
|
| 214 |
+
}
|
| 215 |
+
.dev-entry-dropdown {
|
| 216 |
+
display: none;
|
| 217 |
+
position: absolute;
|
| 218 |
+
right: 0;
|
| 219 |
+
top: 34px;
|
| 220 |
+
background: #1e1e1e;
|
| 221 |
+
border: 1px solid #333;
|
| 222 |
+
border-radius: 8px;
|
| 223 |
+
padding: 4px 0;
|
| 224 |
+
min-width: 170px;
|
| 225 |
+
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
| 226 |
+
}
|
| 227 |
+
.dev-entry:hover .dev-entry-dropdown {
|
| 228 |
+
display: block;
|
| 229 |
+
}
|
| 230 |
+
.dev-entry-dropdown a {
|
| 231 |
+
display: flex;
|
| 232 |
+
align-items: center;
|
| 233 |
+
gap: 8px;
|
| 234 |
+
padding: 8px 14px;
|
| 235 |
+
color: #ccc;
|
| 236 |
+
text-decoration: none;
|
| 237 |
+
font-size: 0.82rem;
|
| 238 |
+
}
|
| 239 |
+
.dev-entry-dropdown a:hover {
|
| 240 |
+
background: rgba(255,255,255,0.08);
|
| 241 |
+
color: #fff;
|
| 242 |
+
}
|
| 243 |
+
"""
|
| 244 |
+
_dev_menu_html = """
|
| 245 |
+
<div class="dev-entry">
|
| 246 |
+
<div class="dev-entry-btn">⋯</div>
|
| 247 |
+
<div class="dev-entry-dropdown">
|
| 248 |
+
<a href="/🔒_Admin" target="_self">🔧 Developer Entry</a>
|
| 249 |
+
</div>
|
| 250 |
+
</div>
|
| 251 |
+
"""
|
| 252 |
+
|
| 253 |
st.markdown(
|
| 254 |
f"""
|
| 255 |
<style>
|
|
|
|
| 276 |
white-space: nowrap;
|
| 277 |
z-index: 999;
|
| 278 |
}}
|
| 279 |
+
{_dev_menu_css}
|
| 280 |
</style>
|
| 281 |
+
{_dev_menu_html}
|
| 282 |
""",
|
| 283 |
unsafe_allow_html=True,
|
| 284 |
)
|
src/dce_analyzer/config.py
CHANGED
|
@@ -130,6 +130,9 @@ class FullModelSpec:
|
|
| 130 |
bws_worst_col: str | None = None
|
| 131 |
estimate_lambda_w: bool = True
|
| 132 |
|
|
|
|
|
|
|
|
|
|
| 133 |
# Latent class
|
| 134 |
n_classes: int = 2
|
| 135 |
membership_cols: list[str] | None = None
|
|
|
|
| 130 |
bws_worst_col: str | None = None
|
| 131 |
estimate_lambda_w: bool = True
|
| 132 |
|
| 133 |
+
# GMNL variant: "general" (gamma free), "s-mnl" (gamma=0), "gmnl-ii" (gamma=1)
|
| 134 |
+
gmnl_variant: str = "general"
|
| 135 |
+
|
| 136 |
# Latent class
|
| 137 |
n_classes: int = 2
|
| 138 |
membership_cols: list[str] | None = None
|
src/dce_analyzer/model.py
CHANGED
|
@@ -716,6 +716,7 @@ class GmnlEstimator(MixedLogitEstimator):
|
|
| 716 |
bws_data: BwsData | None = None,
|
| 717 |
correlated: bool = False,
|
| 718 |
correlation_groups: list[list[int]] | None = None,
|
|
|
|
| 719 |
) -> None:
|
| 720 |
super().__init__(
|
| 721 |
tensors=tensors,
|
|
@@ -727,6 +728,8 @@ class GmnlEstimator(MixedLogitEstimator):
|
|
| 727 |
correlation_groups=correlation_groups,
|
| 728 |
bws_data=bws_data,
|
| 729 |
)
|
|
|
|
|
|
|
| 730 |
# Extra Halton draws for scale heterogeneity (one dim per individual per draw)
|
| 731 |
scale_draws_np = generate_halton_draws(
|
| 732 |
n_individuals=self.n_individuals,
|
|
@@ -738,11 +741,17 @@ class GmnlEstimator(MixedLogitEstimator):
|
|
| 738 |
scale_draws_np[:, :, 0], dtype=torch.float32, device=self.device
|
| 739 |
) # (n_individuals, n_draws)
|
| 740 |
|
| 741 |
-
# Indices for the
|
| 742 |
self._tau_idx = self.n_params # tau (scale mean)
|
| 743 |
self._sigma_tau_idx = self.n_params + 1 # raw sigma_tau (scale SD, softplus)
|
| 744 |
-
|
| 745 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 746 |
|
| 747 |
def _initial_theta(self) -> np.ndarray:
|
| 748 |
# Delegate to parent for MMNL params (handles both independent & correlated)
|
|
@@ -750,10 +759,12 @@ class GmnlEstimator(MixedLogitEstimator):
|
|
| 750 |
# Extend to include GMNL-specific params (already allocated in n_params)
|
| 751 |
if len(theta0) < self.n_params:
|
| 752 |
theta0 = np.concatenate([theta0, np.zeros(self.n_params - len(theta0), dtype=np.float64)])
|
| 753 |
-
# tau=0 -> mean scale=1, raw_sigma_tau=-1 -> small scale SD
|
| 754 |
theta0[self._tau_idx] = 0.0
|
| 755 |
theta0[self._sigma_tau_idx] = -1.0
|
| 756 |
-
|
|
|
|
|
|
|
| 757 |
return theta0
|
| 758 |
|
| 759 |
def _betas_from_theta(self, theta: torch.Tensor) -> torch.Tensor:
|
|
@@ -768,7 +779,10 @@ class GmnlEstimator(MixedLogitEstimator):
|
|
| 768 |
|
| 769 |
tau = theta[self._tau_idx]
|
| 770 |
sigma_tau = _positive(theta[self._sigma_tau_idx])
|
| 771 |
-
|
|
|
|
|
|
|
|
|
|
| 772 |
|
| 773 |
# Scale factor: sigma_i = exp(tau + sigma_tau * epsilon_i)
|
| 774 |
# shape: (n_individuals, n_draws)
|
|
@@ -819,23 +833,32 @@ class GmnlEstimator(MixedLogitEstimator):
|
|
| 819 |
tau_est = float(theta_hat[self._tau_idx])
|
| 820 |
raw_sigma_tau = theta_hat[self._sigma_tau_idx]
|
| 821 |
sigma_tau_est = float(np.logaddexp(0.0, raw_sigma_tau) + 1e-6)
|
| 822 |
-
raw_gamma = theta_hat[self._gamma_idx]
|
| 823 |
-
gamma_est = float(1.0 / (1.0 + np.exp(-raw_gamma)))
|
| 824 |
|
| 825 |
se_tau = float("nan")
|
| 826 |
se_sigma_tau = float("nan")
|
| 827 |
-
se_gamma = float("nan")
|
| 828 |
if vcov is not None:
|
| 829 |
se_tau = float(np.sqrt(max(vcov[self._tau_idx, self._tau_idx], 0.0)))
|
| 830 |
var_raw_st = vcov[self._sigma_tau_idx, self._sigma_tau_idx]
|
| 831 |
deriv_st = self._softplus_derivative(raw_sigma_tau)
|
| 832 |
se_sigma_tau = float(abs(deriv_st) * np.sqrt(max(var_raw_st, 0.0)))
|
| 833 |
-
var_raw_g = vcov[self._gamma_idx, self._gamma_idx]
|
| 834 |
-
deriv_g = gamma_est * (1.0 - gamma_est) # sigmoid derivative
|
| 835 |
-
se_gamma = float(abs(deriv_g) * np.sqrt(max(var_raw_g, 0.0)))
|
| 836 |
|
| 837 |
rows.append(self._make_row("tau (scale mean)", "scale", tau_est, se_tau, theta_index=self._tau_idx))
|
| 838 |
rows.append(self._make_row("sigma_tau (scale SD)", "scale", sigma_tau_est, se_sigma_tau, theta_index=self._sigma_tau_idx))
|
| 839 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 840 |
|
| 841 |
return pd.DataFrame(rows)
|
|
|
|
| 716 |
bws_data: BwsData | None = None,
|
| 717 |
correlated: bool = False,
|
| 718 |
correlation_groups: list[list[int]] | None = None,
|
| 719 |
+
fixed_gamma: float | None = None,
|
| 720 |
) -> None:
|
| 721 |
super().__init__(
|
| 722 |
tensors=tensors,
|
|
|
|
| 728 |
correlation_groups=correlation_groups,
|
| 729 |
bws_data=bws_data,
|
| 730 |
)
|
| 731 |
+
self._fixed_gamma = fixed_gamma # None = free, 0.0 = S-MNL, 1.0 = GMNL-II
|
| 732 |
+
|
| 733 |
# Extra Halton draws for scale heterogeneity (one dim per individual per draw)
|
| 734 |
scale_draws_np = generate_halton_draws(
|
| 735 |
n_individuals=self.n_individuals,
|
|
|
|
| 741 |
scale_draws_np[:, :, 0], dtype=torch.float32, device=self.device
|
| 742 |
) # (n_individuals, n_draws)
|
| 743 |
|
| 744 |
+
# Indices for the extra GMNL parameters appended after MMNL params
|
| 745 |
self._tau_idx = self.n_params # tau (scale mean)
|
| 746 |
self._sigma_tau_idx = self.n_params + 1 # raw sigma_tau (scale SD, softplus)
|
| 747 |
+
if self._fixed_gamma is None:
|
| 748 |
+
# gamma is a free parameter
|
| 749 |
+
self._gamma_idx = self.n_params + 2 # raw gamma (sigmoid -> [0,1])
|
| 750 |
+
self.n_params += 3
|
| 751 |
+
else:
|
| 752 |
+
# gamma is fixed — not a free parameter
|
| 753 |
+
self._gamma_idx = None
|
| 754 |
+
self.n_params += 2
|
| 755 |
|
| 756 |
def _initial_theta(self) -> np.ndarray:
|
| 757 |
# Delegate to parent for MMNL params (handles both independent & correlated)
|
|
|
|
| 759 |
# Extend to include GMNL-specific params (already allocated in n_params)
|
| 760 |
if len(theta0) < self.n_params:
|
| 761 |
theta0 = np.concatenate([theta0, np.zeros(self.n_params - len(theta0), dtype=np.float64)])
|
| 762 |
+
# tau=0 -> mean scale=1, raw_sigma_tau=-1 -> small scale SD
|
| 763 |
theta0[self._tau_idx] = 0.0
|
| 764 |
theta0[self._sigma_tau_idx] = -1.0
|
| 765 |
+
if self._gamma_idx is not None:
|
| 766 |
+
# raw_gamma=0 -> gamma=0.5
|
| 767 |
+
theta0[self._gamma_idx] = 0.0
|
| 768 |
return theta0
|
| 769 |
|
| 770 |
def _betas_from_theta(self, theta: torch.Tensor) -> torch.Tensor:
|
|
|
|
| 779 |
|
| 780 |
tau = theta[self._tau_idx]
|
| 781 |
sigma_tau = _positive(theta[self._sigma_tau_idx])
|
| 782 |
+
if self._fixed_gamma is not None:
|
| 783 |
+
gamma = torch.tensor(self._fixed_gamma, dtype=theta.dtype, device=theta.device)
|
| 784 |
+
else:
|
| 785 |
+
gamma = torch.sigmoid(theta[self._gamma_idx])
|
| 786 |
|
| 787 |
# Scale factor: sigma_i = exp(tau + sigma_tau * epsilon_i)
|
| 788 |
# shape: (n_individuals, n_draws)
|
|
|
|
| 833 |
tau_est = float(theta_hat[self._tau_idx])
|
| 834 |
raw_sigma_tau = theta_hat[self._sigma_tau_idx]
|
| 835 |
sigma_tau_est = float(np.logaddexp(0.0, raw_sigma_tau) + 1e-6)
|
|
|
|
|
|
|
| 836 |
|
| 837 |
se_tau = float("nan")
|
| 838 |
se_sigma_tau = float("nan")
|
|
|
|
| 839 |
if vcov is not None:
|
| 840 |
se_tau = float(np.sqrt(max(vcov[self._tau_idx, self._tau_idx], 0.0)))
|
| 841 |
var_raw_st = vcov[self._sigma_tau_idx, self._sigma_tau_idx]
|
| 842 |
deriv_st = self._softplus_derivative(raw_sigma_tau)
|
| 843 |
se_sigma_tau = float(abs(deriv_st) * np.sqrt(max(var_raw_st, 0.0)))
|
|
|
|
|
|
|
|
|
|
| 844 |
|
| 845 |
rows.append(self._make_row("tau (scale mean)", "scale", tau_est, se_tau, theta_index=self._tau_idx))
|
| 846 |
rows.append(self._make_row("sigma_tau (scale SD)", "scale", sigma_tau_est, se_sigma_tau, theta_index=self._sigma_tau_idx))
|
| 847 |
+
|
| 848 |
+
if self._fixed_gamma is not None:
|
| 849 |
+
# gamma is fixed — report as fixed value with no SE
|
| 850 |
+
rows.append(self._make_row(
|
| 851 |
+
f"gamma (fixed={self._fixed_gamma:.1f})", "scale",
|
| 852 |
+
self._fixed_gamma, float("nan"), theta_index=-1,
|
| 853 |
+
))
|
| 854 |
+
else:
|
| 855 |
+
raw_gamma = theta_hat[self._gamma_idx]
|
| 856 |
+
gamma_est = float(1.0 / (1.0 + np.exp(-raw_gamma)))
|
| 857 |
+
se_gamma = float("nan")
|
| 858 |
+
if vcov is not None:
|
| 859 |
+
var_raw_g = vcov[self._gamma_idx, self._gamma_idx]
|
| 860 |
+
deriv_g = gamma_est * (1.0 - gamma_est) # sigmoid derivative
|
| 861 |
+
se_gamma = float(abs(deriv_g) * np.sqrt(max(var_raw_g, 0.0)))
|
| 862 |
+
rows.append(self._make_row("gamma (mixing)", "scale", gamma_est, se_gamma, theta_index=self._gamma_idx))
|
| 863 |
|
| 864 |
return pd.DataFrame(rows)
|
src/dce_analyzer/pipeline.py
CHANGED
|
@@ -38,6 +38,7 @@ def estimate_dataframe(
|
|
| 38 |
estimate_lambda_w: bool = True,
|
| 39 |
lc_method: str = "em",
|
| 40 |
custom_start: list[float] | None = None,
|
|
|
|
| 41 |
) -> PipelineResult:
|
| 42 |
tensors = prepare_choice_tensors(df, spec, device=device)
|
| 43 |
|
|
@@ -94,6 +95,7 @@ def estimate_dataframe(
|
|
| 94 |
bws_data=bws_data,
|
| 95 |
correlated=correlated,
|
| 96 |
correlation_groups=correlation_groups,
|
|
|
|
| 97 |
)
|
| 98 |
return PipelineResult(
|
| 99 |
tensors=tensors,
|
|
@@ -213,6 +215,7 @@ def estimate_from_spec(
|
|
| 213 |
estimate_lambda_w=spec.estimate_lambda_w,
|
| 214 |
lc_method=spec.lc_method,
|
| 215 |
custom_start=spec.custom_start,
|
|
|
|
| 216 |
)
|
| 217 |
result.expanded_spec = model_spec
|
| 218 |
result.expanded_df = df
|
|
|
|
| 38 |
estimate_lambda_w: bool = True,
|
| 39 |
lc_method: str = "em",
|
| 40 |
custom_start: list[float] | None = None,
|
| 41 |
+
fixed_gamma: float | None = None,
|
| 42 |
) -> PipelineResult:
|
| 43 |
tensors = prepare_choice_tensors(df, spec, device=device)
|
| 44 |
|
|
|
|
| 95 |
bws_data=bws_data,
|
| 96 |
correlated=correlated,
|
| 97 |
correlation_groups=correlation_groups,
|
| 98 |
+
fixed_gamma=fixed_gamma,
|
| 99 |
)
|
| 100 |
return PipelineResult(
|
| 101 |
tensors=tensors,
|
|
|
|
| 215 |
estimate_lambda_w=spec.estimate_lambda_w,
|
| 216 |
lc_method=spec.lc_method,
|
| 217 |
custom_start=spec.custom_start,
|
| 218 |
+
fixed_gamma={"s-mnl": 0.0, "gmnl-ii": 1.0}.get(spec.gmnl_variant),
|
| 219 |
)
|
| 220 |
result.expanded_spec = model_spec
|
| 221 |
result.expanded_df = df
|