oracular 0.5.0 → 0.5.2
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.
- package/CHANGELOG.md +12 -0
- package/README.md +1 -1
- package/dist/cli.js +52 -13
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [#30](https://github.com/Yuribenjamin/oracular/pull/30) [`8be17e0`](https://github.com/Yuribenjamin/oracular/commit/8be17e032bb1b7886ce1d7278701555c39bb03cf) Thanks [@Yuribenjamin](https://github.com/Yuribenjamin)! - `oracular tune` learned three rules from a public e-commerce clickstream of 2.7 million events. A signal seen in more than 90% of counted episodes is left out of the fitted config, whatever its lift: it cannot tell episodes apart, and the few episodes without it are rarely typical sessions, so the contrast measured on them says little (on that log, `item.viewed` sat in 99% of sessions and the contrast called it strongly negative; dropping it scored better than any weight). The current half-life is kept when the best grid point improves on it by less than 1%, and the grid is printed to four decimals so a flat grid reads as flat. The report says when the fitted intent still does not beat its own base rate. The JSON report gains `share` and `ubiquitous` per signal and `beatsBaseRate` per intent.
|
|
8
|
+
|
|
9
|
+
## 0.5.1
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- [#28](https://github.com/Yuribenjamin/oracular/pull/28) [`250a32d`](https://github.com/Yuribenjamin/oracular/commit/250a32d9764aeabdff020f99074c0a512ea7194f) Thanks [@Yuribenjamin](https://github.com/Yuribenjamin)! - `oracular replay` and `oracular tune` read logs of any size. A log of more than about a hundred thousand records made both commands fail with "Maximum call stack size exceeded", because every file's entries were spread into one `push` call. Found by replaying the Retailrocket clickstream (2.7 million events).
|
|
14
|
+
|
|
3
15
|
## 0.5.0
|
|
4
16
|
|
|
5
17
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -382,7 +382,7 @@ npx oracular replay shadow.ndjson --config oracular.config.json
|
|
|
382
382
|
npx oracular tune shadow.ndjson --config oracular.config.json
|
|
383
383
|
```
|
|
384
384
|
|
|
385
|
-
`tune` seeds a config from the same log: grouped by install, the base rate becomes the prior, the spread of per-install base rates the prior strength, each signal's lift over the episodes without it its weight, and the half-life that predicts the log best wins a grid search scored by `replay`. The counts come from the engine itself, so tune cannot disagree with what a device would learn. It prints sample sizes and a 90% interval per weight, and the config to paste; the engine corrects it per user from there. The [Shadow mode guide](https://yuribenjamin.github.io/oracular/guide/shadow-mode) has the workflow, the record format and both commands.
|
|
385
|
+
`tune` seeds a config from the same log: grouped by install, the base rate becomes the prior, the spread of per-install base rates the prior strength, each signal's lift over the episodes without it its weight, and the half-life that predicts the log best wins a grid search scored by `replay`. The counts come from the engine itself, so tune cannot disagree with what a device would learn. It prints sample sizes and a 90% interval per weight, and the config to paste; the engine corrects it per user from there. Three rules learned from a real clickstream keep it honest: a signal seen in nearly every episode is left out, a flat grid keeps your half-life, and the report says when an intent still does not beat its base rate. The [Shadow mode guide](https://yuribenjamin.github.io/oracular/guide/shadow-mode) has the workflow, the record format and both commands.
|
|
386
386
|
|
|
387
387
|
## Design rules
|
|
388
388
|
|
package/dist/cli.js
CHANGED
|
@@ -444,6 +444,8 @@ var DEFAULT_HALF_LIVES = [HOUR2, 6 * HOUR2, DAY2, 3 * DAY2, 7 * DAY2, 14 * DAY2,
|
|
|
444
444
|
var DEFAULT_PRIOR_EPISODES = 10;
|
|
445
445
|
var MIN_INSTALLS_FOR_SPREAD = 10;
|
|
446
446
|
var STRENGTH_RANGE = [2, 50];
|
|
447
|
+
var UBIQUITY_SHARE = 0.9;
|
|
448
|
+
var FLAT_GRID_GAIN = 0.01;
|
|
447
449
|
var Z90 = 1.6449;
|
|
448
450
|
var NONE = { hits: 0, misses: 0 };
|
|
449
451
|
function stepDelta(prev, next) {
|
|
@@ -498,6 +500,8 @@ function fitSignal(signal, current, tally, strength, priorEpisodes) {
|
|
|
498
500
|
const contrast = nWith + without === 0 ? 0 : nWith * without / (nWith + without);
|
|
499
501
|
const [qLow, qHigh] = wilson(hits, nWith, Z90);
|
|
500
502
|
const rounded = Math.round(strength * l * 100) / 100;
|
|
503
|
+
const share = tally.episodes === 0 ? 0 : nWith / tally.episodes;
|
|
504
|
+
const ubiquitous = nWith > UBIQUITY_SHARE * tally.episodes;
|
|
501
505
|
return {
|
|
502
506
|
signal,
|
|
503
507
|
current,
|
|
@@ -508,7 +512,9 @@ function fitSignal(signal, current, tally, strength, priorEpisodes) {
|
|
|
508
512
|
qWith,
|
|
509
513
|
qWithout,
|
|
510
514
|
lift: l,
|
|
511
|
-
weight: nWith === 0 || rounded === 0 ? null : rounded,
|
|
515
|
+
weight: nWith === 0 || ubiquitous || rounded === 0 ? null : rounded,
|
|
516
|
+
share,
|
|
517
|
+
ubiquitous,
|
|
512
518
|
low: strength * lift(qLow, qWithout),
|
|
513
519
|
high: strength * lift(qHigh, qWithout),
|
|
514
520
|
contrast,
|
|
@@ -707,6 +713,7 @@ function tune(entries, config, options = {}) {
|
|
|
707
713
|
return { halfLife: halfLife2, brier: scored?.brier ?? null, points: scored?.points ?? 0 };
|
|
708
714
|
});
|
|
709
715
|
const scoredPoints = curve.filter((p) => p.brier !== null);
|
|
716
|
+
const atCurrent = curve.find((p) => p.halfLife === intent.halfLife);
|
|
710
717
|
let halfLife;
|
|
711
718
|
if (scoredPoints.length === 0) {
|
|
712
719
|
halfLife = {
|
|
@@ -723,17 +730,33 @@ function tune(entries, config, options = {}) {
|
|
|
723
730
|
const ties = scoredPoints.filter((p) => p.brier - best <= 1e-12);
|
|
724
731
|
const nearest = (p) => Math.abs(Math.log(p.halfLife / intent.halfLife));
|
|
725
732
|
const chosen = ties.reduce((a, b) => nearest(b) < nearest(a) ? b : a);
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
733
|
+
const baseline = brierBaseline === null ? "" : ` (always predicting the base rate: ${brierBaseline.toFixed(4)})`;
|
|
734
|
+
const gain = atCurrent.brier === null || atCurrent.brier <= 0 ? Number.POSITIVE_INFINITY : (atCurrent.brier - best) / atCurrent.brier;
|
|
735
|
+
if (chosen.halfLife !== intent.halfLife && gain < FLAT_GRID_GAIN) {
|
|
736
|
+
halfLife = {
|
|
737
|
+
value: intent.halfLife,
|
|
738
|
+
current: intent.halfLife,
|
|
739
|
+
source: "kept",
|
|
740
|
+
note: `kept: the grid is flat, the best point (${formatDuration(chosen.halfLife)}, ${best.toFixed(4)}) improves on the current value (${atCurrent.brier.toFixed(4)}) by ${(gain * 100).toFixed(2)}%, less than ${FLAT_GRID_GAIN * 100}%${baseline}`,
|
|
741
|
+
window,
|
|
742
|
+
curve,
|
|
743
|
+
brierBaseline
|
|
744
|
+
};
|
|
745
|
+
} else {
|
|
746
|
+
halfLife = {
|
|
747
|
+
value: chosen.halfLife,
|
|
748
|
+
current: intent.halfLife,
|
|
749
|
+
source: "grid",
|
|
750
|
+
note: `lowest Brier score over the grid, ${best.toFixed(4)}${baseline}`,
|
|
751
|
+
window,
|
|
752
|
+
curve,
|
|
753
|
+
brierBaseline
|
|
754
|
+
};
|
|
755
|
+
}
|
|
735
756
|
}
|
|
736
757
|
fitted = withIntent(fitted, name, { ...fittedIntent, halfLife: halfLife.value });
|
|
758
|
+
const scoredAt = curve.find((p) => p.halfLife === halfLife.value);
|
|
759
|
+
const beatsBaseRate = scoredAt.brier === null || brierBaseline === null ? null : scoredAt.brier < brierBaseline;
|
|
737
760
|
intents.push({
|
|
738
761
|
intent: name,
|
|
739
762
|
outcome: intent.outcome,
|
|
@@ -745,6 +768,7 @@ function tune(entries, config, options = {}) {
|
|
|
745
768
|
priorStrength: strength,
|
|
746
769
|
halfLife,
|
|
747
770
|
priorEpisodes,
|
|
771
|
+
beatsBaseRate,
|
|
748
772
|
signals,
|
|
749
773
|
candidates
|
|
750
774
|
});
|
|
@@ -771,7 +795,13 @@ var plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
|
771
795
|
function note(fit, priorEpisodes) {
|
|
772
796
|
if (fit.hits + fit.misses === 0) return "never seen in a counted episode";
|
|
773
797
|
const notes = [];
|
|
774
|
-
if (fit.
|
|
798
|
+
if (fit.ubiquitous) {
|
|
799
|
+
notes.push(
|
|
800
|
+
`in ${(fit.share * 100).toFixed(1)}% of episodes, so it cannot tell them apart; left out of the config`
|
|
801
|
+
);
|
|
802
|
+
} else if (fit.weight === null) {
|
|
803
|
+
notes.push("no measurable lift, left out of the config");
|
|
804
|
+
}
|
|
775
805
|
if (fit.thin) notes.push(`thin: ${fit.contrast.toFixed(1)} of ${priorEpisodes} contrast`);
|
|
776
806
|
return notes.join("; ");
|
|
777
807
|
}
|
|
@@ -841,9 +871,18 @@ function formatIntent2(fit) {
|
|
|
841
871
|
out.push("");
|
|
842
872
|
out.push(
|
|
843
873
|
`Half-life grid, Brier score against "the outcome follows within ${formatDuration(fit.halfLife.window)}" (lower is better): ${fit.halfLife.curve.map(
|
|
844
|
-
(p) => `${formatDuration(p.halfLife)} ${num(p.brier,
|
|
874
|
+
(p) => `${formatDuration(p.halfLife)} ${num(p.brier, 4)}${p.halfLife === fit.halfLife.value && p.brier !== null ? " *" : ""}`
|
|
845
875
|
).join(", ")}`
|
|
846
876
|
);
|
|
877
|
+
if (fit.beatsBaseRate === false) {
|
|
878
|
+
const scored = fit.halfLife.curve.find((p) => p.halfLife === fit.halfLife.value);
|
|
879
|
+
const window = formatDuration(fit.halfLife.window);
|
|
880
|
+
const why = fit.signals.some((f) => f.weight !== null) ? `Its signals do not separate the events that ${fit.outcome} follows within ${window} from the rest.` : `No signal is left in the config, so p never leaves the prior, and the prior (the share of episodes that end with ${fit.outcome}) is not the share of events that ${fit.outcome} follows within ${window}.`;
|
|
881
|
+
out.push("");
|
|
882
|
+
out.push(
|
|
883
|
+
`This intent does not beat its base rate: the fitted config scores ${num(scored?.brier ?? null, 4)} where always predicting the base rate scores ${num(fit.halfLife.brierBaseline, 4)} (lower is better). ${why} Drop the intent, or find signals that tell its episodes apart (--all-events measures every other event in the log).`
|
|
884
|
+
);
|
|
885
|
+
}
|
|
847
886
|
if (fit.candidates.length > 0) {
|
|
848
887
|
out.push("");
|
|
849
888
|
out.push(
|
|
@@ -1151,7 +1190,7 @@ function readLogs(files, io) {
|
|
|
1151
1190
|
const parsed = parseLog(io.readFile(resolve(io.cwd, file)), file);
|
|
1152
1191
|
for (const warning of parsed.warnings) io.stderr(`oracular: warning: ${warning}
|
|
1153
1192
|
`);
|
|
1154
|
-
|
|
1193
|
+
for (const entry of parsed.entries) entries.push(entry);
|
|
1155
1194
|
}
|
|
1156
1195
|
if (entries.length === 0) throw new Error("the log holds no records");
|
|
1157
1196
|
return entries;
|