Haicaji commited on
Commit
7affe31
·
1 Parent(s): 4d65913

Add timeout risk model

Browse files
Files changed (6) hide show
  1. README.md +40 -0
  2. predict.py +34 -0
  3. requirements.txt +1 -0
  4. timeout_model.json +197 -0
  5. timeout_risk_model.py +394 -0
  6. train.py +86 -0
README.md CHANGED
@@ -1,3 +1,43 @@
1
  ---
2
  license: mit
3
  ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  license: mit
3
  ---
4
+
5
+ # Huaxiang ChaPanda Timeout Model
6
+
7
+ This repository contains the training and inference code for estimating whether
8
+ Fujian Normal University Huaxiang Garden ChaPanda delivery orders may time out
9
+ from the order time.
10
+
11
+ ## Data Assumption
12
+
13
+ `timeout_records.csv` in the Dataset repository only stores timeout orders, so
14
+ the first model artifact is a positive-only time-risk model. After users submit
15
+ both timeout and non-timeout records through the Space, retraining will switch to
16
+ `labeled_empirical_bayes`, which is closer to a calibrated probability model.
17
+
18
+ ## Train
19
+
20
+ From the local conda environment:
21
+
22
+ ```powershell
23
+ conda activate cbd
24
+ python train.py --dataset-dir ..\FJNU-HuaxiangChaPandaTimeoutRecord
25
+ ```
26
+
27
+ Or download from Hugging Face:
28
+
29
+ ```powershell
30
+ python train.py --download-from-hub
31
+ ```
32
+
33
+ The default output is `timeout_model.json`.
34
+
35
+ ## Predict
36
+
37
+ ```powershell
38
+ python predict.py "2026-05-24 18:30"
39
+ ```
40
+
41
+ The output includes the estimated probability, risk level, model mode, and a
42
+ calibration note. When the model mode is `positive_only_time_density`, treat the
43
+ number as a relative risk estimate until enough non-timeout samples are collected.
predict.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from pathlib import Path
5
+
6
+ from timeout_risk_model import load_model, predict_timeout_probability
7
+
8
+
9
+ def parse_args() -> argparse.Namespace:
10
+ parser = argparse.ArgumentParser(description="Predict timeout probability for one order time.")
11
+ parser.add_argument("order_time", help="Order time, for example: 2026-05-24 18:30")
12
+ parser.add_argument("--product-name", default="", help="Optional product name.")
13
+ parser.add_argument(
14
+ "--model",
15
+ type=Path,
16
+ default=Path(__file__).resolve().parent / "timeout_model.json",
17
+ help="Path to timeout_model.json.",
18
+ )
19
+ return parser.parse_args()
20
+
21
+
22
+ def main() -> None:
23
+ args = parse_args()
24
+ model = load_model(args.model)
25
+ result = predict_timeout_probability(model, args.order_time, args.product_name)
26
+ print(f"order_time: {result['order_time']}")
27
+ print(f"probability: {result['probability']:.1%}")
28
+ print(f"risk_level: {result['risk_level']}")
29
+ print(f"mode: {result['mode']}")
30
+ print(f"calibration: {result['calibration']}")
31
+
32
+
33
+ if __name__ == "__main__":
34
+ main()
requirements.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ huggingface_hub>=0.23.0
timeout_model.json ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "base_probability": 0.35,
3
+ "generated_at": "2026-05-23T18:07:51.470272+00:00",
4
+ "hour_probabilities": {
5
+ "0": {
6
+ "positive_count": 0,
7
+ "probability": 0.12,
8
+ "smoothed_density": 0.0
9
+ },
10
+ "1": {
11
+ "positive_count": 0,
12
+ "probability": 0.12,
13
+ "smoothed_density": 0.0
14
+ },
15
+ "10": {
16
+ "positive_count": 0,
17
+ "probability": 0.1391,
18
+ "smoothed_density": 0.028
19
+ },
20
+ "11": {
21
+ "positive_count": 0,
22
+ "probability": 0.1963,
23
+ "smoothed_density": 0.1121
24
+ },
25
+ "12": {
26
+ "positive_count": 1,
27
+ "probability": 0.3043,
28
+ "smoothed_density": 0.271
29
+ },
30
+ "13": {
31
+ "positive_count": 1,
32
+ "probability": 0.3043,
33
+ "smoothed_density": 0.271
34
+ },
35
+ "14": {
36
+ "positive_count": 0,
37
+ "probability": 0.1963,
38
+ "smoothed_density": 0.1121
39
+ },
40
+ "15": {
41
+ "positive_count": 0,
42
+ "probability": 0.2153,
43
+ "smoothed_density": 0.1402
44
+ },
45
+ "16": {
46
+ "positive_count": 0,
47
+ "probability": 0.406,
48
+ "smoothed_density": 0.4206
49
+ },
50
+ "17": {
51
+ "positive_count": 4,
52
+ "probability": 0.8,
53
+ "smoothed_density": 1.0
54
+ },
55
+ "18": {
56
+ "positive_count": 3,
57
+ "probability": 0.7301,
58
+ "smoothed_density": 0.8972
59
+ },
60
+ "19": {
61
+ "positive_count": 0,
62
+ "probability": 0.3679,
63
+ "smoothed_density": 0.3645
64
+ },
65
+ "2": {
66
+ "positive_count": 0,
67
+ "probability": 0.12,
68
+ "smoothed_density": 0.0
69
+ },
70
+ "20": {
71
+ "positive_count": 0,
72
+ "probability": 0.1772,
73
+ "smoothed_density": 0.0841
74
+ },
75
+ "21": {
76
+ "positive_count": 0,
77
+ "probability": 0.12,
78
+ "smoothed_density": 0.0
79
+ },
80
+ "22": {
81
+ "positive_count": 0,
82
+ "probability": 0.12,
83
+ "smoothed_density": 0.0
84
+ },
85
+ "23": {
86
+ "positive_count": 0,
87
+ "probability": 0.12,
88
+ "smoothed_density": 0.0
89
+ },
90
+ "3": {
91
+ "positive_count": 0,
92
+ "probability": 0.12,
93
+ "smoothed_density": 0.0
94
+ },
95
+ "4": {
96
+ "positive_count": 0,
97
+ "probability": 0.12,
98
+ "smoothed_density": 0.0
99
+ },
100
+ "5": {
101
+ "positive_count": 0,
102
+ "probability": 0.12,
103
+ "smoothed_density": 0.0
104
+ },
105
+ "6": {
106
+ "positive_count": 0,
107
+ "probability": 0.12,
108
+ "smoothed_density": 0.0
109
+ },
110
+ "7": {
111
+ "positive_count": 0,
112
+ "probability": 0.12,
113
+ "smoothed_density": 0.0
114
+ },
115
+ "8": {
116
+ "positive_count": 0,
117
+ "probability": 0.12,
118
+ "smoothed_density": 0.0
119
+ },
120
+ "9": {
121
+ "positive_count": 0,
122
+ "probability": 0.12,
123
+ "smoothed_density": 0.0
124
+ }
125
+ },
126
+ "metadata": {
127
+ "calibration": "Only timeout records were available. Output is a relative time-risk estimate, not a fully calibrated timeout probability.",
128
+ "features": [
129
+ "order hour",
130
+ "weekday",
131
+ "time bucket",
132
+ "optional product name"
133
+ ],
134
+ "non_timeout_records": 0,
135
+ "timeout_records": 9,
136
+ "training_records": 9
137
+ },
138
+ "mode": "positive_only_time_density",
139
+ "model_version": 1,
140
+ "product_probabilities": {},
141
+ "time_bucket_probabilities": {
142
+ "afternoon": {
143
+ "positive_count": 0,
144
+ "probability": 0.16
145
+ },
146
+ "dinner": {
147
+ "positive_count": 7,
148
+ "probability": 0.7
149
+ },
150
+ "late_night": {
151
+ "positive_count": 0,
152
+ "probability": 0.16
153
+ },
154
+ "lunch": {
155
+ "positive_count": 2,
156
+ "probability": 0.3143
157
+ },
158
+ "morning": {
159
+ "positive_count": 0,
160
+ "probability": 0.16
161
+ },
162
+ "night": {
163
+ "positive_count": 0,
164
+ "probability": 0.16
165
+ }
166
+ },
167
+ "weekday_probabilities": {
168
+ "0": {
169
+ "positive_count": 0,
170
+ "probability": 0.18
171
+ },
172
+ "1": {
173
+ "positive_count": 0,
174
+ "probability": 0.18
175
+ },
176
+ "2": {
177
+ "positive_count": 1,
178
+ "probability": 0.29
179
+ },
180
+ "3": {
181
+ "positive_count": 1,
182
+ "probability": 0.29
183
+ },
184
+ "4": {
185
+ "positive_count": 4,
186
+ "probability": 0.62
187
+ },
188
+ "5": {
189
+ "positive_count": 3,
190
+ "probability": 0.51
191
+ },
192
+ "6": {
193
+ "positive_count": 0,
194
+ "probability": 0.18
195
+ }
196
+ }
197
+ }
timeout_risk_model.py ADDED
@@ -0,0 +1,394 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Small timeout-risk model for Huaxiang ChaPanda orders.
2
+
3
+ The first dataset only contains delayed orders, so the model supports two modes:
4
+
5
+ * labeled_empirical_bayes: used when both timeout and non-timeout samples exist.
6
+ * positive_only_time_density: used when only timeout samples exist. This is a
7
+ relative risk estimate by order time, not a calibrated probability.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import csv
13
+ import json
14
+ import math
15
+ from collections import Counter, defaultdict
16
+ from dataclasses import dataclass
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+ from typing import Any, Callable, Iterable
20
+
21
+ MODEL_VERSION = 1
22
+ CSV_FIELDS = [
23
+ "product_name",
24
+ "order_time",
25
+ "is_timeout",
26
+ "expected_latest_time",
27
+ "actual_arrival_time",
28
+ "submitted_at",
29
+ "notes",
30
+ ]
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class TrainingRecord:
35
+ product_name: str
36
+ order_time: datetime
37
+ is_timeout: bool
38
+ source: str
39
+
40
+
41
+ def parse_datetime(value: Any) -> datetime | None:
42
+ if value is None:
43
+ return None
44
+ text = str(value).strip()
45
+ if not text:
46
+ return None
47
+
48
+ normalized = text.replace("/", "-").replace("T", " ")
49
+ if len(normalized) == 16:
50
+ normalized = f"{normalized}:00"
51
+
52
+ formats = (
53
+ "%Y-%m-%d %H:%M:%S",
54
+ "%Y-%m-%d %H:%M",
55
+ "%Y-%m-%d",
56
+ )
57
+ for fmt in formats:
58
+ try:
59
+ return datetime.strptime(normalized, fmt)
60
+ except ValueError:
61
+ pass
62
+
63
+ try:
64
+ return datetime.fromisoformat(normalized)
65
+ except ValueError:
66
+ return None
67
+
68
+
69
+ def normalize_row(row: dict[str, Any]) -> dict[str, str]:
70
+ return {str(key).strip(): str(value).strip() for key, value in row.items() if key is not None}
71
+
72
+
73
+ def coerce_bool(value: Any) -> bool | None:
74
+ if value is None:
75
+ return None
76
+ text = str(value).strip().lower()
77
+ if not text:
78
+ return None
79
+ true_values = {"1", "true", "yes", "y", "timeout", "late", "超时", "是", "yes_timeout"}
80
+ false_values = {"0", "false", "no", "n", "on_time", "not_timeout", "未超时", "否"}
81
+ if text in true_values:
82
+ return True
83
+ if text in false_values:
84
+ return False
85
+ if "未超时" in text or "准时" in text:
86
+ return False
87
+ if "超时" in text or "延迟" in text:
88
+ return True
89
+ return None
90
+
91
+
92
+ def infer_timeout_label(row: dict[str, str], forced_timeout: bool | None = None) -> bool | None:
93
+ if forced_timeout is not None:
94
+ return forced_timeout
95
+
96
+ for key in ("is_timeout", "timeout", "timeout_status", "status"):
97
+ parsed = coerce_bool(row.get(key))
98
+ if parsed is not None:
99
+ return parsed
100
+
101
+ expected = parse_datetime(row.get("expected_latest_time"))
102
+ actual = parse_datetime(row.get("actual_arrival_time"))
103
+ if expected and actual:
104
+ return actual > expected
105
+ return None
106
+
107
+
108
+ def read_csv_records(path: Path, forced_timeout: bool | None = None) -> list[TrainingRecord]:
109
+ if not path.exists():
110
+ return []
111
+
112
+ records: list[TrainingRecord] = []
113
+ with path.open("r", encoding="utf-8-sig", newline="") as handle:
114
+ reader = csv.DictReader(handle)
115
+ for raw_row in reader:
116
+ row = normalize_row(raw_row)
117
+ order_time = parse_datetime(row.get("order_time"))
118
+ is_timeout = infer_timeout_label(row, forced_timeout=forced_timeout)
119
+ if not order_time or is_timeout is None:
120
+ continue
121
+
122
+ product_name = row.get("product_name", "").strip() or "unknown"
123
+ records.append(
124
+ TrainingRecord(
125
+ product_name=product_name,
126
+ order_time=order_time,
127
+ is_timeout=is_timeout,
128
+ source=str(path),
129
+ )
130
+ )
131
+ return records
132
+
133
+
134
+ def load_dataset_records(dataset_dir: str | Path) -> list[TrainingRecord]:
135
+ dataset_path = Path(dataset_dir)
136
+ records = read_csv_records(dataset_path / "timeout_records.csv", forced_timeout=True)
137
+
138
+ tmp_dir = dataset_path / "tmp"
139
+ if tmp_dir.exists():
140
+ for tmp_file in sorted(tmp_dir.glob("tmp_records_*.csv")):
141
+ records.extend(read_csv_records(tmp_file, forced_timeout=None))
142
+
143
+ return records
144
+
145
+
146
+ def _time_bucket(dt: datetime) -> str:
147
+ hour = dt.hour
148
+ if 6 <= hour < 11:
149
+ return "morning"
150
+ if 11 <= hour < 14:
151
+ return "lunch"
152
+ if 14 <= hour < 17:
153
+ return "afternoon"
154
+ if 17 <= hour < 20:
155
+ return "dinner"
156
+ if 20 <= hour < 24:
157
+ return "night"
158
+ return "late_night"
159
+
160
+
161
+ def _sigmoid(value: float) -> float:
162
+ return 1.0 / (1.0 + math.exp(-value))
163
+
164
+
165
+ def _logit(probability: float) -> float:
166
+ clipped = min(max(probability, 0.02), 0.98)
167
+ return math.log(clipped / (1.0 - clipped))
168
+
169
+
170
+ def _clip_probability(value: float) -> float:
171
+ return round(min(max(value, 0.02), 0.98), 4)
172
+
173
+
174
+ def _empirical_probabilities(
175
+ records: list[TrainingRecord],
176
+ key_fn: Callable[[TrainingRecord], str],
177
+ base_probability: float,
178
+ prior_strength: float,
179
+ ) -> dict[str, dict[str, float | int]]:
180
+ grouped: dict[str, list[TrainingRecord]] = defaultdict(list)
181
+ for record in records:
182
+ grouped[key_fn(record)].append(record)
183
+
184
+ stats: dict[str, dict[str, float | int]] = {}
185
+ for key, items in grouped.items():
186
+ total = len(items)
187
+ timeouts = sum(1 for item in items if item.is_timeout)
188
+ probability = (timeouts + prior_strength * base_probability) / (total + prior_strength)
189
+ stats[str(key)] = {
190
+ "total": total,
191
+ "timeouts": timeouts,
192
+ "probability": _clip_probability(probability),
193
+ }
194
+ return stats
195
+
196
+
197
+ def _positive_density_probabilities(
198
+ records: list[TrainingRecord],
199
+ key_fn: Callable[[TrainingRecord], str],
200
+ keys: Iterable[str],
201
+ low: float,
202
+ high: float,
203
+ ) -> dict[str, dict[str, float | int]]:
204
+ counts = Counter(key_fn(record) for record in records if record.is_timeout)
205
+ max_count = max(counts.values(), default=0)
206
+ output: dict[str, dict[str, float | int]] = {}
207
+ for key in keys:
208
+ count = counts.get(key, 0)
209
+ density = count / max_count if max_count else 0.0
210
+ probability = low + (high - low) * density
211
+ output[str(key)] = {
212
+ "positive_count": count,
213
+ "probability": _clip_probability(probability),
214
+ }
215
+ return output
216
+
217
+
218
+ def _smoothed_hour_density(records: list[TrainingRecord]) -> dict[str, dict[str, float | int]]:
219
+ raw_counts = Counter(record.order_time.hour for record in records if record.is_timeout)
220
+ smoothed: dict[int, float] = {}
221
+ for hour in range(24):
222
+ smoothed[hour] = (
223
+ raw_counts.get(hour, 0)
224
+ + 0.45 * raw_counts.get((hour - 1) % 24, 0)
225
+ + 0.45 * raw_counts.get((hour + 1) % 24, 0)
226
+ + 0.15 * raw_counts.get((hour - 2) % 24, 0)
227
+ + 0.15 * raw_counts.get((hour + 2) % 24, 0)
228
+ )
229
+ max_score = max(smoothed.values(), default=0.0)
230
+
231
+ output: dict[str, dict[str, float | int]] = {}
232
+ for hour in range(24):
233
+ density = smoothed[hour] / max_score if max_score else 0.0
234
+ probability = 0.12 + 0.68 * density
235
+ output[str(hour)] = {
236
+ "positive_count": raw_counts.get(hour, 0),
237
+ "smoothed_density": round(density, 4),
238
+ "probability": _clip_probability(probability),
239
+ }
240
+ return output
241
+
242
+
243
+ def train_timeout_model(records: list[TrainingRecord]) -> dict[str, Any]:
244
+ total = len(records)
245
+ positives = sum(1 for record in records if record.is_timeout)
246
+ negatives = total - positives
247
+ generated_at = datetime.now(timezone.utc).isoformat()
248
+
249
+ if total == 0:
250
+ return {
251
+ "model_version": MODEL_VERSION,
252
+ "mode": "empty_default",
253
+ "generated_at": generated_at,
254
+ "base_probability": 0.35,
255
+ "hour_probabilities": {str(hour): {"probability": 0.35, "total": 0, "timeouts": 0} for hour in range(24)},
256
+ "weekday_probabilities": {str(day): {"probability": 0.35, "total": 0, "timeouts": 0} for day in range(7)},
257
+ "time_bucket_probabilities": {},
258
+ "product_probabilities": {},
259
+ "metadata": {
260
+ "training_records": 0,
261
+ "timeout_records": 0,
262
+ "non_timeout_records": 0,
263
+ "calibration": "No valid records were found. Default probability is a placeholder.",
264
+ },
265
+ }
266
+
267
+ if positives > 0 and negatives > 0:
268
+ base_probability = (positives + 2.0) / (total + 4.0)
269
+ mode = "labeled_empirical_bayes"
270
+ calibration = "Calibrated from user-labeled timeout and non-timeout records with Bayesian smoothing."
271
+ hour_probabilities = _empirical_probabilities(records, lambda r: str(r.order_time.hour), base_probability, 6.0)
272
+ weekday_probabilities = _empirical_probabilities(records, lambda r: str(r.order_time.weekday()), base_probability, 8.0)
273
+ time_bucket_probabilities = _empirical_probabilities(records, lambda r: _time_bucket(r.order_time), base_probability, 5.0)
274
+ product_probabilities = _empirical_probabilities(records, lambda r: r.product_name, base_probability, 8.0)
275
+ else:
276
+ base_probability = 0.35
277
+ mode = "positive_only_time_density"
278
+ calibration = (
279
+ "Only timeout records were available. Output is a relative time-risk estimate, "
280
+ "not a fully calibrated timeout probability."
281
+ )
282
+ positive_records = [record for record in records if record.is_timeout]
283
+ hour_probabilities = _smoothed_hour_density(positive_records)
284
+ weekday_probabilities = _positive_density_probabilities(
285
+ positive_records,
286
+ lambda r: str(r.order_time.weekday()),
287
+ [str(day) for day in range(7)],
288
+ low=0.18,
289
+ high=0.62,
290
+ )
291
+ time_bucket_probabilities = _positive_density_probabilities(
292
+ positive_records,
293
+ lambda r: _time_bucket(r.order_time),
294
+ ["morning", "lunch", "afternoon", "dinner", "night", "late_night"],
295
+ low=0.16,
296
+ high=0.70,
297
+ )
298
+ product_probabilities = {}
299
+
300
+ return {
301
+ "model_version": MODEL_VERSION,
302
+ "mode": mode,
303
+ "generated_at": generated_at,
304
+ "base_probability": _clip_probability(base_probability),
305
+ "hour_probabilities": hour_probabilities,
306
+ "weekday_probabilities": weekday_probabilities,
307
+ "time_bucket_probabilities": time_bucket_probabilities,
308
+ "product_probabilities": product_probabilities,
309
+ "metadata": {
310
+ "training_records": total,
311
+ "timeout_records": positives,
312
+ "non_timeout_records": negatives,
313
+ "calibration": calibration,
314
+ "features": ["order hour", "weekday", "time bucket", "optional product name"],
315
+ },
316
+ }
317
+
318
+
319
+ def load_model(path: str | Path) -> dict[str, Any]:
320
+ with Path(path).open("r", encoding="utf-8") as handle:
321
+ return json.load(handle)
322
+
323
+
324
+ def save_model(model: dict[str, Any], path: str | Path) -> None:
325
+ output_path = Path(path)
326
+ output_path.parent.mkdir(parents=True, exist_ok=True)
327
+ with output_path.open("w", encoding="utf-8") as handle:
328
+ json.dump(model, handle, ensure_ascii=False, indent=2, sort_keys=True)
329
+ handle.write("\n")
330
+
331
+
332
+ def predict_timeout_probability(
333
+ model: dict[str, Any],
334
+ order_time: str | datetime,
335
+ product_name: str = "",
336
+ ) -> dict[str, Any]:
337
+ dt = order_time if isinstance(order_time, datetime) else parse_datetime(order_time)
338
+ if dt is None:
339
+ raise ValueError("order_time must look like '2026-05-24 18:30'.")
340
+
341
+ base = float(model.get("base_probability", 0.35))
342
+ mode = model.get("mode", "unknown")
343
+
344
+ def component_probability(section: str, key: str, fallback: float) -> float:
345
+ value = model.get(section, {}).get(str(key), {})
346
+ try:
347
+ return float(value.get("probability", fallback))
348
+ except (TypeError, ValueError):
349
+ return fallback
350
+
351
+ hour_probability = component_probability("hour_probabilities", str(dt.hour), base)
352
+ weekday_probability = component_probability("weekday_probabilities", str(dt.weekday()), base)
353
+ bucket_probability = component_probability("time_bucket_probabilities", _time_bucket(dt), base)
354
+ product_probability = None
355
+ if product_name:
356
+ product_probability = component_probability("product_probabilities", product_name.strip(), base)
357
+
358
+ if mode == "labeled_empirical_bayes":
359
+ combined_logit = _logit(base)
360
+ combined_logit += 0.58 * (_logit(hour_probability) - _logit(base))
361
+ combined_logit += 0.18 * (_logit(weekday_probability) - _logit(base))
362
+ combined_logit += 0.16 * (_logit(bucket_probability) - _logit(base))
363
+ if product_probability is not None:
364
+ combined_logit += 0.08 * (_logit(product_probability) - _logit(base))
365
+ probability = _sigmoid(combined_logit)
366
+ else:
367
+ combined_logit = _logit(base)
368
+ combined_logit += 0.70 * (_logit(hour_probability) - _logit(base))
369
+ combined_logit += 0.18 * (_logit(bucket_probability) - _logit(base))
370
+ combined_logit += 0.12 * (_logit(weekday_probability) - _logit(base))
371
+ probability = _sigmoid(combined_logit)
372
+
373
+ clipped = _clip_probability(probability)
374
+ if clipped >= 0.65:
375
+ level = "high"
376
+ elif clipped >= 0.4:
377
+ level = "medium"
378
+ else:
379
+ level = "low"
380
+
381
+ return {
382
+ "probability": clipped,
383
+ "risk_level": level,
384
+ "mode": mode,
385
+ "order_time": dt.strftime("%Y-%m-%d %H:%M"),
386
+ "components": {
387
+ "base": base,
388
+ "hour": hour_probability,
389
+ "weekday": weekday_probability,
390
+ "time_bucket": bucket_probability,
391
+ "product": product_probability,
392
+ },
393
+ "calibration": model.get("metadata", {}).get("calibration", ""),
394
+ }
train.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import shutil
5
+ import tempfile
6
+ from pathlib import Path
7
+
8
+ from timeout_risk_model import load_dataset_records, save_model, train_timeout_model
9
+
10
+ DEFAULT_DATASET_REPO_ID = "stack-all/FJNU-HuaxiangChaPandaTimeoutRecord"
11
+
12
+
13
+ def default_dataset_dir() -> Path:
14
+ return Path(__file__).resolve().parent.parent / "FJNU-HuaxiangChaPandaTimeoutRecord"
15
+
16
+
17
+ def download_dataset_snapshot(repo_id: str) -> Path:
18
+ try:
19
+ from huggingface_hub import snapshot_download
20
+ except ImportError as exc:
21
+ raise RuntimeError("Install huggingface_hub or use --dataset-dir with a local dataset checkout.") from exc
22
+
23
+ target_dir = Path(tempfile.mkdtemp(prefix="cha_panda_dataset_"))
24
+ snapshot_download(
25
+ repo_id=repo_id,
26
+ repo_type="dataset",
27
+ local_dir=str(target_dir),
28
+ allow_patterns=["timeout_records.csv", "tmp/*.csv"],
29
+ )
30
+ return target_dir
31
+
32
+
33
+ def parse_args() -> argparse.Namespace:
34
+ parser = argparse.ArgumentParser(description="Train the Huaxiang ChaPanda timeout risk model.")
35
+ parser.add_argument(
36
+ "--dataset-dir",
37
+ type=Path,
38
+ default=default_dataset_dir(),
39
+ help="Local checkout of the Dataset repository.",
40
+ )
41
+ parser.add_argument(
42
+ "--dataset-repo-id",
43
+ default=DEFAULT_DATASET_REPO_ID,
44
+ help="Hugging Face Dataset repo id used with --download-from-hub.",
45
+ )
46
+ parser.add_argument(
47
+ "--download-from-hub",
48
+ action="store_true",
49
+ help="Download timeout_records.csv and tmp/*.csv from the Dataset repository before training.",
50
+ )
51
+ parser.add_argument(
52
+ "--output",
53
+ type=Path,
54
+ default=Path(__file__).resolve().parent / "timeout_model.json",
55
+ help="Output JSON model path.",
56
+ )
57
+ return parser.parse_args()
58
+
59
+
60
+ def main() -> None:
61
+ args = parse_args()
62
+ downloaded_dir: Path | None = None
63
+ dataset_dir = args.dataset_dir
64
+
65
+ if args.download_from_hub:
66
+ downloaded_dir = download_dataset_snapshot(args.dataset_repo_id)
67
+ dataset_dir = downloaded_dir
68
+
69
+ try:
70
+ records = load_dataset_records(dataset_dir)
71
+ model = train_timeout_model(records)
72
+ save_model(model, args.output)
73
+ finally:
74
+ if downloaded_dir:
75
+ shutil.rmtree(downloaded_dir, ignore_errors=True)
76
+
77
+ metadata = model["metadata"]
78
+ print(f"Saved model: {args.output}")
79
+ print(f"Mode: {model['mode']}")
80
+ print(f"Training records: {metadata['training_records']}")
81
+ print(f"Timeout / non-timeout: {metadata['timeout_records']} / {metadata['non_timeout_records']}")
82
+ print(f"Calibration: {metadata['calibration']}")
83
+
84
+
85
+ if __name__ == "__main__":
86
+ main()