cacheverifier 0.3.0__tar.gz → 0.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {cacheverifier-0.3.0 → cacheverifier-0.4.0}/CHANGELOG.md +23 -0
- {cacheverifier-0.3.0 → cacheverifier-0.4.0}/PKG-INFO +23 -1
- {cacheverifier-0.3.0 → cacheverifier-0.4.0}/README.md +22 -0
- {cacheverifier-0.3.0 → cacheverifier-0.4.0}/cacheverifier/__init__.py +1 -1
- cacheverifier-0.4.0/cacheverifier/_healthcheck/_base_models.py +62 -0
- {cacheverifier-0.3.0 → cacheverifier-0.4.0}/cacheverifier/_healthcheck/_finetune.py +53 -11
- {cacheverifier-0.3.0 → cacheverifier-0.4.0}/cacheverifier/_healthcheck/cli.py +42 -3
- {cacheverifier-0.3.0 → cacheverifier-0.4.0}/pyproject.toml +1 -1
- cacheverifier-0.4.0/tests/test_healthcheck.py +205 -0
- cacheverifier-0.3.0/tests/test_healthcheck.py +0 -100
- {cacheverifier-0.3.0 → cacheverifier-0.4.0}/.github/workflows/ci.yml +0 -0
- {cacheverifier-0.3.0 → cacheverifier-0.4.0}/.github/workflows/publish.yml +0 -0
- {cacheverifier-0.3.0 → cacheverifier-0.4.0}/.gitignore +0 -0
- {cacheverifier-0.3.0 → cacheverifier-0.4.0}/LICENSE +0 -0
- {cacheverifier-0.3.0 → cacheverifier-0.4.0}/cacheverifier/__main__.py +0 -0
- {cacheverifier-0.3.0 → cacheverifier-0.4.0}/cacheverifier/_healthcheck/__init__.py +0 -0
- {cacheverifier-0.3.0 → cacheverifier-0.4.0}/cacheverifier/client.py +0 -0
- {cacheverifier-0.3.0 → cacheverifier-0.4.0}/cacheverifier/integrations/__init__.py +0 -0
- {cacheverifier-0.3.0 → cacheverifier-0.4.0}/cacheverifier/integrations/gptcache.py +0 -0
- {cacheverifier-0.3.0 → cacheverifier-0.4.0}/cacheverifier/py.typed +0 -0
- {cacheverifier-0.3.0 → cacheverifier-0.4.0}/examples/gptcache_example.py +0 -0
- {cacheverifier-0.3.0 → cacheverifier-0.4.0}/examples/quickstart.py +0 -0
- {cacheverifier-0.3.0 → cacheverifier-0.4.0}/tests/test_client.py +0 -0
- {cacheverifier-0.3.0 → cacheverifier-0.4.0}/tests/test_gptcache_integration.py +0 -0
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.0
|
|
4
|
+
|
|
5
|
+
`cacheverifier healthcheck --base-model` picks the model the local Health
|
|
6
|
+
Check starts from: `ms_marco` (default, unchanged), `nli`, `multilingual` or
|
|
7
|
+
`multilingual_small` — the same choices as the hosted service's `base_model`
|
|
8
|
+
— or any Hugging Face model id / local model directory. Chinese and other
|
|
9
|
+
non-English traffic should use a multilingual base; the CLI now warns when
|
|
10
|
+
most of the input is CJK text and the base is English-only. The NLI base's
|
|
11
|
+
3-class head is scored `P(entailment) - P(contradiction)` for the stock
|
|
12
|
+
baseline and re-initialized to a single logit for fine-tuning, matching the
|
|
13
|
+
hosted service. `--emit-summary` records the base model (a local directory
|
|
14
|
+
is recorded only as "local directory").
|
|
15
|
+
|
|
16
|
+
## 0.3.1
|
|
17
|
+
|
|
18
|
+
`cacheverifier healthcheck` now actually fine-tunes. It passed no
|
|
19
|
+
`warmup_steps` to `CrossEncoder.fit`, whose default of 10000 kept a typical
|
|
20
|
+
run below a few percent of its learning rate, so `auc_tuned` came back equal
|
|
21
|
+
(or nearly equal) to `auc_baseline`. Warmup is now 10% of total steps and the
|
|
22
|
+
default is 3 epochs (`--epochs`), the same as the hosted service since
|
|
23
|
+
verifier-core 0.2.0. On a 500-row later holdout this took LmArena AUC from
|
|
24
|
+
0.903 to 0.932 and AmazonHelp from 0.551 to 0.700. Runs take about 3x as long.
|
|
25
|
+
|
|
3
26
|
## 0.3.0
|
|
4
27
|
|
|
5
28
|
Make the request-path defaults safe. `verify()` and `verify_batch()` now use a
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: cacheverifier
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Python client for the hosted CacheVerifier semantic-cache verification API
|
|
5
5
|
Project-URL: Homepage, https://www.cacheverifier.com
|
|
6
6
|
Project-URL: Documentation, https://www.cacheverifier.com/docs
|
|
@@ -177,6 +177,28 @@ Nothing is sent anywhere — the base model downloads once from Hugging Face, th
|
|
|
177
177
|
fully offline. `--emit-summary` writes an aggregate-only JSON file (AUCs, counts, rates —
|
|
178
178
|
no query or answer text) that's safe to share for a human read.
|
|
179
179
|
|
|
180
|
+
**Choosing the base model.** `--base-model` takes the same values as the hosted
|
|
181
|
+
service's `base_model` fine-tune parameter, so local and hosted numbers stay comparable:
|
|
182
|
+
|
|
183
|
+
| `--base-model` | model | use for |
|
|
184
|
+
|---|---|---|
|
|
185
|
+
| `ms_marco` (default) | `cross-encoder/ms-marco-MiniLM-L6-v2` | English traffic |
|
|
186
|
+
| `nli` | `cross-encoder/nli-MiniLM2-L6-H768` | English; entailment pretraining, stronger on negation / entity swaps |
|
|
187
|
+
| `multilingual` | `BAAI/bge-reranker-base` | Chinese and other non-English traffic (~6 GB RAM to fine-tune) |
|
|
188
|
+
| `multilingual_small` | `cross-encoder/mmarco-mMiniLMv2-L12-H384-v1` | same, smaller (~3 GB RAM) |
|
|
189
|
+
|
|
190
|
+
The English bases read most Chinese characters as unknown tokens, so on Chinese
|
|
191
|
+
traffic their result doesn't mean anything — the CLI warns when it sees mostly CJK text
|
|
192
|
+
on an English base. You can also pass any Hugging Face model id or a local model
|
|
193
|
+
directory. If Hugging Face is unreachable from your network, set `HF_ENDPOINT` to a
|
|
194
|
+
mirror, or download the model elsewhere and pass its directory:
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
cacheverifier healthcheck traffic.jsonl --base-model multilingual
|
|
198
|
+
HF_ENDPOINT=https://hf-mirror.com cacheverifier healthcheck traffic.jsonl --base-model multilingual_small
|
|
199
|
+
cacheverifier healthcheck traffic.jsonl --base-model ./models/bge-reranker-base
|
|
200
|
+
```
|
|
201
|
+
|
|
180
202
|
```
|
|
181
203
|
results
|
|
182
204
|
------------------------------------------------------------------
|
|
@@ -145,6 +145,28 @@ Nothing is sent anywhere — the base model downloads once from Hugging Face, th
|
|
|
145
145
|
fully offline. `--emit-summary` writes an aggregate-only JSON file (AUCs, counts, rates —
|
|
146
146
|
no query or answer text) that's safe to share for a human read.
|
|
147
147
|
|
|
148
|
+
**Choosing the base model.** `--base-model` takes the same values as the hosted
|
|
149
|
+
service's `base_model` fine-tune parameter, so local and hosted numbers stay comparable:
|
|
150
|
+
|
|
151
|
+
| `--base-model` | model | use for |
|
|
152
|
+
|---|---|---|
|
|
153
|
+
| `ms_marco` (default) | `cross-encoder/ms-marco-MiniLM-L6-v2` | English traffic |
|
|
154
|
+
| `nli` | `cross-encoder/nli-MiniLM2-L6-H768` | English; entailment pretraining, stronger on negation / entity swaps |
|
|
155
|
+
| `multilingual` | `BAAI/bge-reranker-base` | Chinese and other non-English traffic (~6 GB RAM to fine-tune) |
|
|
156
|
+
| `multilingual_small` | `cross-encoder/mmarco-mMiniLMv2-L12-H384-v1` | same, smaller (~3 GB RAM) |
|
|
157
|
+
|
|
158
|
+
The English bases read most Chinese characters as unknown tokens, so on Chinese
|
|
159
|
+
traffic their result doesn't mean anything — the CLI warns when it sees mostly CJK text
|
|
160
|
+
on an English base. You can also pass any Hugging Face model id or a local model
|
|
161
|
+
directory. If Hugging Face is unreachable from your network, set `HF_ENDPOINT` to a
|
|
162
|
+
mirror, or download the model elsewhere and pass its directory:
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
cacheverifier healthcheck traffic.jsonl --base-model multilingual
|
|
166
|
+
HF_ENDPOINT=https://hf-mirror.com cacheverifier healthcheck traffic.jsonl --base-model multilingual_small
|
|
167
|
+
cacheverifier healthcheck traffic.jsonl --base-model ./models/bge-reranker-base
|
|
168
|
+
```
|
|
169
|
+
|
|
148
170
|
```
|
|
149
171
|
results
|
|
150
172
|
------------------------------------------------------------------
|
|
@@ -18,5 +18,5 @@ Research behind it: https://github.com/imxinchengyou/CacheVerifier
|
|
|
18
18
|
|
|
19
19
|
from cacheverifier.client import CacheVerifier, CacheVerifierError, VerifyResult
|
|
20
20
|
|
|
21
|
-
__version__ = "0.
|
|
21
|
+
__version__ = "0.4.0"
|
|
22
22
|
__all__ = ["CacheVerifier", "CacheVerifierError", "VerifyResult", "__version__"]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Base-model choices for `cacheverifier healthcheck --base-model`.
|
|
2
|
+
|
|
3
|
+
Kept free of torch / sentence-transformers so `cacheverifier healthcheck
|
|
4
|
+
--help` can list the choices without the `healthcheck` extra installed.
|
|
5
|
+
The aliases and model ids match the hosted service's `base_model` fine-tune
|
|
6
|
+
parameter, so a local run is comparable to `POST /v1/finetune/dry-run`
|
|
7
|
+
with the same value.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
DEFAULT_BASE_MODEL = "cross-encoder/ms-marco-MiniLM-L6-v2"
|
|
16
|
+
"""The stock off-the-shelf verifier every tenant starts on -- the same base
|
|
17
|
+
model the hosted service fine-tunes from. English only."""
|
|
18
|
+
|
|
19
|
+
BASE_MODEL_CHOICES: dict[str, str] = {
|
|
20
|
+
"ms_marco": DEFAULT_BASE_MODEL,
|
|
21
|
+
# SNLI+MultiNLI entailment pretraining, same size as ms_marco. Ships a
|
|
22
|
+
# 3-class head; see _finetune for how it is scored and fine-tuned.
|
|
23
|
+
"nli": "cross-encoder/nli-MiniLM2-L6-H768",
|
|
24
|
+
# Non-English (e.g. Chinese) traffic: the English bases turn most
|
|
25
|
+
# Chinese characters into [UNK] at tokenization.
|
|
26
|
+
"multilingual": "BAAI/bge-reranker-base",
|
|
27
|
+
"multilingual_small": "cross-encoder/mmarco-mMiniLMv2-L12-H384-v1",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
ENGLISH_ONLY_BASE_MODELS = frozenset({BASE_MODEL_CHOICES["ms_marco"], BASE_MODEL_CHOICES["nli"]})
|
|
31
|
+
|
|
32
|
+
_CJK_RE = re.compile(r"[-ヿ㐀-䶿一-鿿가-]")
|
|
33
|
+
|
|
34
|
+
CJK_WARNING_RATIO = 0.3
|
|
35
|
+
"""Share of non-whitespace characters that are CJK above which an
|
|
36
|
+
English-only base model gets a warning."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def resolve_base_model(value: str) -> str:
|
|
40
|
+
"""An alias from `BASE_MODEL_CHOICES`, a Hugging Face model id
|
|
41
|
+
("org/name"), or a local model directory (e.g. one downloaded from a
|
|
42
|
+
mirror or ModelScope). Raises ValueError for anything else, so a typo'd
|
|
43
|
+
alias fails immediately instead of after a failed download."""
|
|
44
|
+
if value in BASE_MODEL_CHOICES:
|
|
45
|
+
return BASE_MODEL_CHOICES[value]
|
|
46
|
+
# `Path("")` is the current directory -- never treat an empty value as
|
|
47
|
+
# a local model.
|
|
48
|
+
if value.strip() and Path(value).expanduser().is_dir():
|
|
49
|
+
return str(Path(value).expanduser())
|
|
50
|
+
if "/" in value and not value.startswith("/"):
|
|
51
|
+
return value
|
|
52
|
+
raise ValueError(
|
|
53
|
+
f"unknown base model {value!r}: use one of {', '.join(BASE_MODEL_CHOICES)}, "
|
|
54
|
+
"a Hugging Face model id (org/name), or a local model directory"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def cjk_ratio(texts: list[str]) -> float:
|
|
59
|
+
chars = "".join("".join(t.split()) for t in texts)
|
|
60
|
+
if not chars:
|
|
61
|
+
return 0.0
|
|
62
|
+
return len(_CJK_RE.findall(chars)) / len(chars)
|
|
@@ -24,15 +24,17 @@ import torch
|
|
|
24
24
|
from sentence_transformers import CrossEncoder, InputExample
|
|
25
25
|
from torch.utils.data import DataLoader
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
from cacheverifier._healthcheck._base_models import DEFAULT_BASE_MODEL
|
|
28
|
+
|
|
29
|
+
# The four third-party imports above are from the `healthcheck` extra. This module is
|
|
28
30
|
# only ever imported from inside `cacheverifier._healthcheck.cli.run()`,
|
|
29
31
|
# which catches the ImportError and points the user at
|
|
30
32
|
# `pip install "cacheverifier[healthcheck]"` -- so a plain
|
|
31
33
|
# `import cacheverifier` / `cacheverifier --help` never pays this cost.
|
|
32
34
|
|
|
33
|
-
|
|
34
|
-
"""
|
|
35
|
-
|
|
35
|
+
NLI_LABEL_ORDER = ("contradiction", "entailment", "neutral")
|
|
36
|
+
"""`cross-encoder/nli-*` models' class order, used when the loaded model
|
|
37
|
+
doesn't expose an `id2label` naming them."""
|
|
36
38
|
|
|
37
39
|
MAX_SEQUENCE_LENGTH = 128
|
|
38
40
|
"""Token cap applied identically at train and score time. Matches the
|
|
@@ -43,6 +45,13 @@ _CONTENT_TOKEN_BUDGET = MAX_SEQUENCE_LENGTH - 3 # [CLS] + 2x [SEP]
|
|
|
43
45
|
_MIN_QUERY_TOKENS = 32
|
|
44
46
|
|
|
45
47
|
TRAIN_BATCH_SIZE = 4
|
|
48
|
+
TRAIN_EPOCHS = 3
|
|
49
|
+
TRAIN_WARMUP_FRACTION = 0.1
|
|
50
|
+
"""Same as the hosted service (verifier-core 0.2.0). `CrossEncoder.fit`
|
|
51
|
+
defaults to `warmup_steps=10000`; without an explicit value a typical run
|
|
52
|
+
(a few hundred steps) never got past a few percent of its learning rate and
|
|
53
|
+
the "fine-tuned" model was a near monotone shift of the base model, so
|
|
54
|
+
`auc_tuned` came back equal to `auc_baseline`."""
|
|
46
55
|
MIN_TRAIN_EXAMPLES = 20
|
|
47
56
|
"""Hard floor -- below this there aren't enough rows for one meaningful
|
|
48
57
|
epoch plus a held-out split."""
|
|
@@ -178,12 +187,34 @@ def _diagnose_ceiling(scores: np.ndarray, labels: np.ndarray) -> str | None:
|
|
|
178
187
|
return "still_improvable"
|
|
179
188
|
|
|
180
189
|
|
|
190
|
+
def _nli_column_indices(model: Any) -> tuple[int, int]:
|
|
191
|
+
"""(entailment_idx, contradiction_idx) for a 3-class NLI cross-encoder."""
|
|
192
|
+
id2label = getattr(getattr(model, "config", None), "id2label", None)
|
|
193
|
+
if isinstance(id2label, dict) and len(id2label) == 3:
|
|
194
|
+
lut = {str(v).lower(): int(k) for k, v in id2label.items()}
|
|
195
|
+
if "entailment" in lut and "contradiction" in lut:
|
|
196
|
+
return lut["entailment"], lut["contradiction"]
|
|
197
|
+
return NLI_LABEL_ORDER.index("entailment"), NLI_LABEL_ORDER.index("contradiction")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def score_pairs(model: Any, pairs: list[tuple[str, str]], batch_size: int = 32) -> np.ndarray:
|
|
201
|
+
"""Higher means "more likely an acceptable answer". A 1-logit model
|
|
202
|
+
(ms-marco, the multilingual rerankers, anything fine-tuned here) returns
|
|
203
|
+
its raw logit; a 3-class NLI model returns P(entailment) - P(contradiction)
|
|
204
|
+
-- the same scoring the hosted service uses for the stock NLI baseline."""
|
|
205
|
+
if int(model.config.num_labels) == 3:
|
|
206
|
+
ent, contra = _nli_column_indices(model)
|
|
207
|
+
probs = np.asarray(model.predict(pairs, batch_size=batch_size, show_progress_bar=False, apply_softmax=True))
|
|
208
|
+
return probs[:, ent] - probs[:, contra]
|
|
209
|
+
return np.asarray(model.predict(pairs, batch_size=batch_size, show_progress_bar=False))
|
|
210
|
+
|
|
211
|
+
|
|
181
212
|
def run_healthcheck(
|
|
182
213
|
examples: list[GrayZoneExample],
|
|
183
214
|
output_dir: str,
|
|
184
215
|
*,
|
|
185
216
|
base_model: str = DEFAULT_BASE_MODEL,
|
|
186
|
-
epochs: int =
|
|
217
|
+
epochs: int = TRAIN_EPOCHS,
|
|
187
218
|
) -> HealthCheckResult:
|
|
188
219
|
"""Fine-tune `base_model` on a chronological prefix of `examples` and
|
|
189
220
|
measure held-out AUC for the stock vs. fine-tuned model.
|
|
@@ -214,11 +245,21 @@ def run_healthcheck(
|
|
|
214
245
|
|
|
215
246
|
baseline = CrossEncoder(base_model, device=device, max_length=MAX_SEQUENCE_LENGTH)
|
|
216
247
|
test_pairs_scored = [smart_truncate_pair(q, a, baseline.tokenizer) for q, a in test_pairs]
|
|
217
|
-
baseline_scores =
|
|
248
|
+
baseline_scores = score_pairs(baseline, test_pairs_scored)
|
|
218
249
|
auc_baseline = roc_auc(baseline_scores, test_labels)
|
|
219
250
|
del baseline
|
|
220
251
|
|
|
221
|
-
|
|
252
|
+
# num_labels=1 forces a single-logit head, matching the hosted service: a
|
|
253
|
+
# no-op for 1-logit bases, a deliberate head reinitialization (encoder
|
|
254
|
+
# body kept) for the 3-class NLI base. ignore_mismatched_sizes lets that
|
|
255
|
+
# 3->1 reinit happen instead of raising.
|
|
256
|
+
tuned = CrossEncoder(
|
|
257
|
+
base_model,
|
|
258
|
+
num_labels=1,
|
|
259
|
+
device=device,
|
|
260
|
+
max_length=MAX_SEQUENCE_LENGTH,
|
|
261
|
+
automodel_args={"ignore_mismatched_sizes": True},
|
|
262
|
+
)
|
|
222
263
|
train_examples = [
|
|
223
264
|
InputExample(
|
|
224
265
|
texts=list(smart_truncate_pair(e.query, e.candidate_answer, tuned.tokenizer)),
|
|
@@ -233,15 +274,16 @@ def run_healthcheck(
|
|
|
233
274
|
# whether or not torch's stubs are installed (base CI has no torch).
|
|
234
275
|
loader: DataLoader[Any] = DataLoader(cast(Any, train_examples), shuffle=True, batch_size=TRAIN_BATCH_SIZE)
|
|
235
276
|
t0 = time.time()
|
|
236
|
-
|
|
277
|
+
warmup_steps = max(1, int(TRAIN_WARMUP_FRACTION * len(loader) * epochs))
|
|
278
|
+
tuned.fit(train_dataloader=loader, epochs=epochs, warmup_steps=warmup_steps, show_progress_bar=False)
|
|
237
279
|
train_time_seconds = time.time() - t0
|
|
238
280
|
tuned.save(output_dir)
|
|
239
281
|
|
|
240
282
|
calib_scored = [smart_truncate_pair(q, a, tuned.tokenizer) for q, a in calib_pairs]
|
|
241
|
-
calib_scores =
|
|
283
|
+
calib_scores = score_pairs(tuned, calib_scored)
|
|
242
284
|
threshold = select_threshold(calib_scores, calib_labels)
|
|
243
285
|
|
|
244
|
-
tuned_scores =
|
|
286
|
+
tuned_scores = score_pairs(tuned, test_pairs_scored)
|
|
245
287
|
auc_tuned = roc_auc(tuned_scores, test_labels)
|
|
246
288
|
ceiling_status = _diagnose_ceiling(tuned_scores, test_labels)
|
|
247
289
|
|
|
@@ -251,7 +293,7 @@ def run_healthcheck(
|
|
|
251
293
|
if threshold is not None:
|
|
252
294
|
threshold_hit_rate, threshold_error_rate = _hit_and_error_rate(tuned_scores, test_labels, threshold)
|
|
253
295
|
train_pairs_scored = [smart_truncate_pair(e.query, e.candidate_answer, tuned.tokenizer) for e in train_rows]
|
|
254
|
-
train_scores =
|
|
296
|
+
train_scores = score_pairs(tuned, train_pairs_scored)
|
|
255
297
|
train_labels = np.array([1 if e.was_correct else 0 for e in train_rows])
|
|
256
298
|
train_pred = (train_scores >= threshold).astype(int)
|
|
257
299
|
train_label_disagreement_rate = float(np.mean(train_pred != train_labels))
|
|
@@ -13,6 +13,14 @@ import sys
|
|
|
13
13
|
from pathlib import Path
|
|
14
14
|
from typing import Any
|
|
15
15
|
|
|
16
|
+
from cacheverifier._healthcheck._base_models import (
|
|
17
|
+
BASE_MODEL_CHOICES,
|
|
18
|
+
CJK_WARNING_RATIO,
|
|
19
|
+
ENGLISH_ONLY_BASE_MODELS,
|
|
20
|
+
cjk_ratio,
|
|
21
|
+
resolve_base_model,
|
|
22
|
+
)
|
|
23
|
+
|
|
16
24
|
_MISSING_EXTRA_HINT = (
|
|
17
25
|
"the healthcheck extra is not installed -- run:\n"
|
|
18
26
|
' pip install "cacheverifier[healthcheck]"\n'
|
|
@@ -48,8 +56,19 @@ def add_subparser(subparsers: argparse._SubParsersAction) -> None:
|
|
|
48
56
|
p.add_argument(
|
|
49
57
|
"--epochs",
|
|
50
58
|
type=int,
|
|
51
|
-
default=
|
|
52
|
-
help="fine-tuning epochs (default:
|
|
59
|
+
default=3,
|
|
60
|
+
help="fine-tuning epochs (default: 3, matching the hosted service)",
|
|
61
|
+
)
|
|
62
|
+
p.add_argument(
|
|
63
|
+
"--base-model",
|
|
64
|
+
default="ms_marco",
|
|
65
|
+
metavar="MODEL",
|
|
66
|
+
help=(
|
|
67
|
+
f"model to start from: {', '.join(BASE_MODEL_CHOICES)} (same choices as the hosted "
|
|
68
|
+
"service's base_model), a Hugging Face model id, or a local model directory. "
|
|
69
|
+
"Default ms_marco is English-only -- use multilingual or multilingual_small for "
|
|
70
|
+
"Chinese or other non-English traffic (they need several GB of RAM to fine-tune)"
|
|
71
|
+
),
|
|
53
72
|
)
|
|
54
73
|
p.add_argument(
|
|
55
74
|
"--keep-model",
|
|
@@ -125,6 +144,12 @@ def run(args: argparse.Namespace) -> int:
|
|
|
125
144
|
print(f"error: {e}", file=sys.stderr)
|
|
126
145
|
return 2
|
|
127
146
|
|
|
147
|
+
try:
|
|
148
|
+
base_model = resolve_base_model(args.base_model)
|
|
149
|
+
except ValueError as e:
|
|
150
|
+
print(f"error: {e}", file=sys.stderr)
|
|
151
|
+
return 2
|
|
152
|
+
|
|
128
153
|
n = len(examples)
|
|
129
154
|
print(f"\nCacheVerifier local Health Check -- {input_path}")
|
|
130
155
|
print("=" * 66)
|
|
@@ -133,6 +158,17 @@ def run(args: argparse.Namespace) -> int:
|
|
|
133
158
|
if n_stale:
|
|
134
159
|
print(f"stale, excluded: {n_stale}")
|
|
135
160
|
|
|
161
|
+
print(f"base model: {base_model}")
|
|
162
|
+
if base_model in ENGLISH_ONLY_BASE_MODELS:
|
|
163
|
+
ratio = cjk_ratio([t for e in examples for t in (e.query, e.candidate_answer)])
|
|
164
|
+
if ratio > CJK_WARNING_RATIO:
|
|
165
|
+
print(
|
|
166
|
+
f"\nWarning: {ratio:.0%} of your text is Chinese/Japanese/Korean, but {base_model} is an "
|
|
167
|
+
"English-only model\n (it reads most CJK characters as unknown tokens), so this "
|
|
168
|
+
"result won't reflect your\n traffic. Re-run with --base-model multilingual "
|
|
169
|
+
"(or multilingual_small)."
|
|
170
|
+
)
|
|
171
|
+
|
|
136
172
|
if n < _finetune.MIN_TRAIN_EXAMPLES:
|
|
137
173
|
print(f"\nNeed at least {_finetune.MIN_TRAIN_EXAMPLES} non-stale rows to run; have {n}.", file=sys.stderr)
|
|
138
174
|
return 2
|
|
@@ -149,7 +185,7 @@ def run(args: argparse.Namespace) -> int:
|
|
|
149
185
|
print(f"\nfine-tuning locally (nothing sent) -> {out_dir}")
|
|
150
186
|
print("this takes a few minutes on CPU; the base model downloads once on first run...\n")
|
|
151
187
|
|
|
152
|
-
result = _finetune.run_healthcheck(examples, out_dir, base_model=
|
|
188
|
+
result = _finetune.run_healthcheck(examples, out_dir, base_model=base_model, epochs=args.epochs)
|
|
153
189
|
|
|
154
190
|
print("results")
|
|
155
191
|
print("-" * 66)
|
|
@@ -175,6 +211,9 @@ def run(args: argparse.Namespace) -> int:
|
|
|
175
211
|
|
|
176
212
|
if args.emit_summary is not None:
|
|
177
213
|
summary = {
|
|
214
|
+
# A local directory's path can carry a username -- this file is
|
|
215
|
+
# meant to be shareable, so record only that it was local.
|
|
216
|
+
"base_model": "local directory" if Path(base_model).is_dir() else base_model,
|
|
178
217
|
"n_rows_read": len(rows),
|
|
179
218
|
"n_usable": n,
|
|
180
219
|
"n_stale_excluded": n_stale,
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""CLI dispatch + Health Check runner.
|
|
2
|
+
|
|
3
|
+
The dispatch/parsing tests run everywhere (base install). The end-to-end
|
|
4
|
+
training test needs the `healthcheck` extra and is skipped when torch is
|
|
5
|
+
absent -- CI's base matrix skips it; the dedicated `healthcheck` job runs
|
|
6
|
+
it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import importlib.util
|
|
12
|
+
import json
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
import pytest
|
|
16
|
+
|
|
17
|
+
from cacheverifier.__main__ import main
|
|
18
|
+
from cacheverifier._healthcheck import cli
|
|
19
|
+
|
|
20
|
+
HAS_TORCH = importlib.util.find_spec("torch") is not None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class _DummyExample:
|
|
25
|
+
query: str
|
|
26
|
+
candidate_answer: str
|
|
27
|
+
was_correct: bool
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TestDispatch:
|
|
31
|
+
def test_version(self, capsys):
|
|
32
|
+
with pytest.raises(SystemExit) as e:
|
|
33
|
+
main(["--version"])
|
|
34
|
+
assert e.value.code == 0
|
|
35
|
+
from cacheverifier import __version__
|
|
36
|
+
|
|
37
|
+
assert f"cacheverifier {__version__}" in capsys.readouterr().out
|
|
38
|
+
|
|
39
|
+
def test_no_command_prints_help_and_returns_1(self, capsys):
|
|
40
|
+
assert main([]) == 1
|
|
41
|
+
assert "healthcheck" in capsys.readouterr().out
|
|
42
|
+
|
|
43
|
+
def test_healthcheck_help(self):
|
|
44
|
+
with pytest.raises(SystemExit) as e:
|
|
45
|
+
main(["healthcheck", "--help"])
|
|
46
|
+
assert e.value.code == 0
|
|
47
|
+
|
|
48
|
+
@pytest.mark.skipif(HAS_TORCH, reason="torch present -- the missing-extra path can't be exercised")
|
|
49
|
+
def test_healthcheck_without_extra_points_at_pip_install(self, capsys, tmp_path):
|
|
50
|
+
f = tmp_path / "t.jsonl"
|
|
51
|
+
f.write_text("")
|
|
52
|
+
assert main(["healthcheck", str(f)]) == 2
|
|
53
|
+
assert 'pip install "cacheverifier[healthcheck]"' in capsys.readouterr().err
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class TestParsing:
|
|
57
|
+
def test_load_rows_accepts_json_array_and_jsonl(self, tmp_path):
|
|
58
|
+
rows = [{"a": 1}, {"a": 2}]
|
|
59
|
+
arr = tmp_path / "a.json"
|
|
60
|
+
arr.write_text(json.dumps(rows))
|
|
61
|
+
jsonl = tmp_path / "a.jsonl"
|
|
62
|
+
jsonl.write_text("\n".join(json.dumps(r) for r in rows) + "\n")
|
|
63
|
+
assert cli._load_rows(arr) == rows
|
|
64
|
+
assert cli._load_rows(jsonl) == rows
|
|
65
|
+
|
|
66
|
+
def test_to_examples_skips_stale_and_counts_them(self):
|
|
67
|
+
rows = [
|
|
68
|
+
{"query": "q1", "candidate_answer": "a1", "was_correct": True},
|
|
69
|
+
{"query": "q2", "candidate_answer": "a2", "was_correct": False, "stale": True},
|
|
70
|
+
]
|
|
71
|
+
examples, n_stale = cli._to_examples(rows, _DummyExample)
|
|
72
|
+
assert len(examples) == 1
|
|
73
|
+
assert n_stale == 1
|
|
74
|
+
assert examples[0] == _DummyExample("q1", "a1", True)
|
|
75
|
+
|
|
76
|
+
def test_to_examples_rejects_missing_keys(self):
|
|
77
|
+
with pytest.raises(ValueError, match="missing key"):
|
|
78
|
+
cli._to_examples([{"query": "q"}], _DummyExample)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@pytest.mark.skipif(not HAS_TORCH, reason="needs the healthcheck extra (torch, sentence-transformers)")
|
|
82
|
+
def test_run_healthcheck_end_to_end(tmp_path):
|
|
83
|
+
from cacheverifier._healthcheck._finetune import GrayZoneExample, HealthCheckResult, run_healthcheck
|
|
84
|
+
|
|
85
|
+
groups = [
|
|
86
|
+
("how do I cancel my subscription", "Settings > Billing > Cancel.", "Settings > Billing > Pause a month."),
|
|
87
|
+
("how do I get a refund", "Orders > Return > Request refund.", "Orders > Return > Exchange size."),
|
|
88
|
+
("how do I reset my password", "Account > Security > Reset password.", "Account > Security > Enable 2FA."),
|
|
89
|
+
]
|
|
90
|
+
examples = []
|
|
91
|
+
for i in range(36):
|
|
92
|
+
q, good, bad = groups[i % len(groups)]
|
|
93
|
+
correct = i % 2 == 0
|
|
94
|
+
examples.append(GrayZoneExample(q, good if correct else bad, correct))
|
|
95
|
+
|
|
96
|
+
result = run_healthcheck(examples, str(tmp_path / "model"), epochs=1)
|
|
97
|
+
assert isinstance(result, HealthCheckResult)
|
|
98
|
+
assert result.n_train + result.n_calibrate + result.n_test == 36
|
|
99
|
+
assert 0.0 <= result.auc_tuned <= 1.0
|
|
100
|
+
assert (tmp_path / "model").is_dir()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@pytest.mark.skipif(not HAS_TORCH, reason="needs the healthcheck extra (torch)")
|
|
104
|
+
def test_run_healthcheck_warms_up_over_a_tenth_of_its_steps(tmp_path, monkeypatch):
|
|
105
|
+
# CrossEncoder.fit defaults to warmup_steps=10000, which kept a
|
|
106
|
+
# few-hundred-step run from ever reaching its learning rate.
|
|
107
|
+
from sentence_transformers import CrossEncoder
|
|
108
|
+
|
|
109
|
+
from cacheverifier._healthcheck import _finetune
|
|
110
|
+
from cacheverifier._healthcheck._finetune import GrayZoneExample, run_healthcheck
|
|
111
|
+
|
|
112
|
+
seen: dict = {}
|
|
113
|
+
real_fit = CrossEncoder.fit
|
|
114
|
+
|
|
115
|
+
def spy_fit(self, *args, **kwargs):
|
|
116
|
+
seen.update(kwargs, steps_per_epoch=len(kwargs["train_dataloader"]))
|
|
117
|
+
return real_fit(self, *args, **kwargs)
|
|
118
|
+
|
|
119
|
+
monkeypatch.setattr(CrossEncoder, "fit", spy_fit)
|
|
120
|
+
examples = [GrayZoneExample(f"question {i}", f"answer {i % 3}", i % 2 == 0) for i in range(40)]
|
|
121
|
+
run_healthcheck(examples, str(tmp_path / "model"))
|
|
122
|
+
|
|
123
|
+
assert seen["epochs"] == _finetune.TRAIN_EPOCHS == 3
|
|
124
|
+
expected = max(1, int(_finetune.TRAIN_WARMUP_FRACTION * seen["steps_per_epoch"] * seen["epochs"]))
|
|
125
|
+
assert seen["warmup_steps"] == expected
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class TestBaseModel:
|
|
129
|
+
def test_aliases_match_the_hosted_service(self):
|
|
130
|
+
from cacheverifier._healthcheck._base_models import BASE_MODEL_CHOICES, resolve_base_model
|
|
131
|
+
|
|
132
|
+
assert set(BASE_MODEL_CHOICES) == {"ms_marco", "nli", "multilingual", "multilingual_small"}
|
|
133
|
+
assert resolve_base_model("ms_marco") == "cross-encoder/ms-marco-MiniLM-L6-v2"
|
|
134
|
+
assert resolve_base_model("multilingual") == "BAAI/bge-reranker-base"
|
|
135
|
+
|
|
136
|
+
def test_hf_id_and_local_dir_pass_through(self, tmp_path):
|
|
137
|
+
from cacheverifier._healthcheck._base_models import resolve_base_model
|
|
138
|
+
|
|
139
|
+
assert resolve_base_model("someorg/some-reranker") == "someorg/some-reranker"
|
|
140
|
+
assert resolve_base_model(str(tmp_path)) == str(tmp_path)
|
|
141
|
+
|
|
142
|
+
@pytest.mark.parametrize("bad", ["multilingal", "/no/such/model/dir", ""])
|
|
143
|
+
def test_typos_and_missing_paths_are_rejected(self, bad):
|
|
144
|
+
from cacheverifier._healthcheck._base_models import resolve_base_model
|
|
145
|
+
|
|
146
|
+
with pytest.raises(ValueError, match="unknown base model"):
|
|
147
|
+
resolve_base_model(bad)
|
|
148
|
+
|
|
149
|
+
def test_cjk_ratio(self):
|
|
150
|
+
from cacheverifier._healthcheck._base_models import cjk_ratio
|
|
151
|
+
|
|
152
|
+
assert cjk_ratio(["how do I cancel"]) == 0.0
|
|
153
|
+
assert cjk_ratio(["怎么取消订阅"]) == 1.0
|
|
154
|
+
assert cjk_ratio(["怎么取消 subscription"]) == 4 / 16
|
|
155
|
+
assert cjk_ratio([]) == 0.0
|
|
156
|
+
|
|
157
|
+
def test_help_lists_base_model_choices(self, capsys):
|
|
158
|
+
with pytest.raises(SystemExit):
|
|
159
|
+
main(["healthcheck", "--help"])
|
|
160
|
+
out = capsys.readouterr().out
|
|
161
|
+
assert "--base-model" in out and "multilingual_small" in out
|
|
162
|
+
|
|
163
|
+
@pytest.mark.skipif(not HAS_TORCH, reason="run() imports the healthcheck extra first")
|
|
164
|
+
def test_unknown_base_model_is_an_input_error(self, capsys, tmp_path):
|
|
165
|
+
f = tmp_path / "t.jsonl"
|
|
166
|
+
f.write_text(json.dumps({"query": "q", "candidate_answer": "a", "was_correct": True}) + "\n")
|
|
167
|
+
assert main(["healthcheck", str(f), "--base-model", "multilingal"]) == 2
|
|
168
|
+
assert "unknown base model" in capsys.readouterr().err
|
|
169
|
+
|
|
170
|
+
@pytest.mark.skipif(not HAS_TORCH, reason="run() imports the healthcheck extra first")
|
|
171
|
+
def test_chinese_data_on_english_model_warns(self, capsys, tmp_path):
|
|
172
|
+
# Below MIN_TRAIN_EXAMPLES, so run() stops before any download/training.
|
|
173
|
+
f = tmp_path / "t.jsonl"
|
|
174
|
+
f.write_text(
|
|
175
|
+
"\n".join(
|
|
176
|
+
json.dumps({"query": "怎么取消自动续费", "candidate_answer": "进入设置取消", "was_correct": True}, ensure_ascii=False)
|
|
177
|
+
for _ in range(3)
|
|
178
|
+
)
|
|
179
|
+
)
|
|
180
|
+
assert main(["healthcheck", str(f)]) == 2
|
|
181
|
+
out = capsys.readouterr().out
|
|
182
|
+
assert "English-only" in out and "--base-model multilingual" in out
|
|
183
|
+
|
|
184
|
+
assert main(["healthcheck", str(f), "--base-model", "multilingual_small"]) == 2
|
|
185
|
+
assert "English-only" not in capsys.readouterr().out
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@pytest.mark.skipif(not HAS_TORCH, reason="needs the healthcheck extra (torch, sentence-transformers)")
|
|
189
|
+
def test_run_healthcheck_nli_base_reinitializes_to_single_logit(tmp_path):
|
|
190
|
+
"""The NLI base ships a 3-class head: the stock baseline is scored
|
|
191
|
+
P(entailment) - P(contradiction), and fine-tuning reinitializes it to the
|
|
192
|
+
same 1-logit head as every other base (matching the hosted service)."""
|
|
193
|
+
from sentence_transformers import CrossEncoder
|
|
194
|
+
|
|
195
|
+
from cacheverifier._healthcheck._base_models import BASE_MODEL_CHOICES
|
|
196
|
+
from cacheverifier._healthcheck._finetune import GrayZoneExample, run_healthcheck
|
|
197
|
+
|
|
198
|
+
examples = [
|
|
199
|
+
GrayZoneExample("how do I cancel my plan", "Settings > Billing > Cancel." if i % 2 == 0 else "Settings > Pause.", i % 2 == 0)
|
|
200
|
+
for i in range(24)
|
|
201
|
+
]
|
|
202
|
+
result = run_healthcheck(examples, str(tmp_path / "model"), base_model=BASE_MODEL_CHOICES["nli"], epochs=1)
|
|
203
|
+
assert 0.0 <= result.auc_baseline <= 1.0
|
|
204
|
+
assert 0.0 <= result.auc_tuned <= 1.0
|
|
205
|
+
assert CrossEncoder(str(tmp_path / "model")).config.num_labels == 1
|
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
"""CLI dispatch + Health Check runner.
|
|
2
|
-
|
|
3
|
-
The dispatch/parsing tests run everywhere (base install). The end-to-end
|
|
4
|
-
training test needs the `healthcheck` extra and is skipped when torch is
|
|
5
|
-
absent -- CI's base matrix skips it; the dedicated `healthcheck` job runs
|
|
6
|
-
it.
|
|
7
|
-
"""
|
|
8
|
-
|
|
9
|
-
from __future__ import annotations
|
|
10
|
-
|
|
11
|
-
import importlib.util
|
|
12
|
-
import json
|
|
13
|
-
from dataclasses import dataclass
|
|
14
|
-
|
|
15
|
-
import pytest
|
|
16
|
-
|
|
17
|
-
from cacheverifier.__main__ import main
|
|
18
|
-
from cacheverifier._healthcheck import cli
|
|
19
|
-
|
|
20
|
-
HAS_TORCH = importlib.util.find_spec("torch") is not None
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
@dataclass(frozen=True)
|
|
24
|
-
class _DummyExample:
|
|
25
|
-
query: str
|
|
26
|
-
candidate_answer: str
|
|
27
|
-
was_correct: bool
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
class TestDispatch:
|
|
31
|
-
def test_version(self, capsys):
|
|
32
|
-
with pytest.raises(SystemExit) as e:
|
|
33
|
-
main(["--version"])
|
|
34
|
-
assert e.value.code == 0
|
|
35
|
-
from cacheverifier import __version__
|
|
36
|
-
|
|
37
|
-
assert f"cacheverifier {__version__}" in capsys.readouterr().out
|
|
38
|
-
|
|
39
|
-
def test_no_command_prints_help_and_returns_1(self, capsys):
|
|
40
|
-
assert main([]) == 1
|
|
41
|
-
assert "healthcheck" in capsys.readouterr().out
|
|
42
|
-
|
|
43
|
-
def test_healthcheck_help(self):
|
|
44
|
-
with pytest.raises(SystemExit) as e:
|
|
45
|
-
main(["healthcheck", "--help"])
|
|
46
|
-
assert e.value.code == 0
|
|
47
|
-
|
|
48
|
-
@pytest.mark.skipif(HAS_TORCH, reason="torch present -- the missing-extra path can't be exercised")
|
|
49
|
-
def test_healthcheck_without_extra_points_at_pip_install(self, capsys, tmp_path):
|
|
50
|
-
f = tmp_path / "t.jsonl"
|
|
51
|
-
f.write_text("")
|
|
52
|
-
assert main(["healthcheck", str(f)]) == 2
|
|
53
|
-
assert 'pip install "cacheverifier[healthcheck]"' in capsys.readouterr().err
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
class TestParsing:
|
|
57
|
-
def test_load_rows_accepts_json_array_and_jsonl(self, tmp_path):
|
|
58
|
-
rows = [{"a": 1}, {"a": 2}]
|
|
59
|
-
arr = tmp_path / "a.json"
|
|
60
|
-
arr.write_text(json.dumps(rows))
|
|
61
|
-
jsonl = tmp_path / "a.jsonl"
|
|
62
|
-
jsonl.write_text("\n".join(json.dumps(r) for r in rows) + "\n")
|
|
63
|
-
assert cli._load_rows(arr) == rows
|
|
64
|
-
assert cli._load_rows(jsonl) == rows
|
|
65
|
-
|
|
66
|
-
def test_to_examples_skips_stale_and_counts_them(self):
|
|
67
|
-
rows = [
|
|
68
|
-
{"query": "q1", "candidate_answer": "a1", "was_correct": True},
|
|
69
|
-
{"query": "q2", "candidate_answer": "a2", "was_correct": False, "stale": True},
|
|
70
|
-
]
|
|
71
|
-
examples, n_stale = cli._to_examples(rows, _DummyExample)
|
|
72
|
-
assert len(examples) == 1
|
|
73
|
-
assert n_stale == 1
|
|
74
|
-
assert examples[0] == _DummyExample("q1", "a1", True)
|
|
75
|
-
|
|
76
|
-
def test_to_examples_rejects_missing_keys(self):
|
|
77
|
-
with pytest.raises(ValueError, match="missing key"):
|
|
78
|
-
cli._to_examples([{"query": "q"}], _DummyExample)
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
@pytest.mark.skipif(not HAS_TORCH, reason="needs the healthcheck extra (torch, sentence-transformers)")
|
|
82
|
-
def test_run_healthcheck_end_to_end(tmp_path):
|
|
83
|
-
from cacheverifier._healthcheck._finetune import GrayZoneExample, HealthCheckResult, run_healthcheck
|
|
84
|
-
|
|
85
|
-
groups = [
|
|
86
|
-
("how do I cancel my subscription", "Settings > Billing > Cancel.", "Settings > Billing > Pause a month."),
|
|
87
|
-
("how do I get a refund", "Orders > Return > Request refund.", "Orders > Return > Exchange size."),
|
|
88
|
-
("how do I reset my password", "Account > Security > Reset password.", "Account > Security > Enable 2FA."),
|
|
89
|
-
]
|
|
90
|
-
examples = []
|
|
91
|
-
for i in range(36):
|
|
92
|
-
q, good, bad = groups[i % len(groups)]
|
|
93
|
-
correct = i % 2 == 0
|
|
94
|
-
examples.append(GrayZoneExample(q, good if correct else bad, correct))
|
|
95
|
-
|
|
96
|
-
result = run_healthcheck(examples, str(tmp_path / "model"), epochs=1)
|
|
97
|
-
assert isinstance(result, HealthCheckResult)
|
|
98
|
-
assert result.n_train + result.n_calibrate + result.n_test == 36
|
|
99
|
-
assert 0.0 <= result.auc_tuned <= 1.0
|
|
100
|
-
assert (tmp_path / "model").is_dir()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|