artaquest commited on
Commit
572e15d
·
verified ·
1 Parent(s): 61b42b9

ArtaModel: deployed 6-term boosted-split model (train+test), card, study, code

Browse files
ARTAMODEL.md ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ArtaModel — the study
2
+
3
+ *Named by Arash, 2026-08-18. Third-edition gendered data (dad/mom from P21; both births to the day, both
4
+ birthplaces; the wedding date; sidereal phases from Kerykeion at 09:00 local). 250 fits in
5
+ `artamodel_study.py`; every number below is held out on couples born after 1900 unless marked "inner", every
6
+ choice made on the inner temporal split, and every experiment on a FIXED population.*
7
+
8
+ ## The model
9
+
10
+ ```
11
+ y = | b + Σᵢ aᵢ ·e^{i(θmᵢ − θdᵢ)} synastry, mom − dad
12
+ + mᵢ ·e^{i(θtᵢ − θmᵢ)} the wedding sky transiting mom
13
+ + dᵢ ·e^{i(θtᵢ − θdᵢ)} the wedding sky transiting dad
14
+ + mnᵢ·e^{i θmᵢ} mom's own natal phase
15
+ + dnᵢ·e^{i θdᵢ} dad's own natal phase
16
+ + tnᵢ·e^{i θtᵢ} the wedding sky's own phase
17
+ + cᵢ ·e^{i θcᵢ} the composite (shorter-arc midpoint of the two natal longitudes)
18
+ + tcᵢ·e^{i(θtᵢ − θcᵢ)} |² the wedding sky transiting the composite
19
+ ```
20
+
21
+ Fourteen bodies (Sun … Pluto, true node = Rāhu, true south node = Ketu, Chiron, mean Lilith), Ascendant/MC
22
+ optional. Every term exists only when both of its phases exist ("if wedding is not known, drop the last two
23
+ terms"; "if dob of either is not known, drop the natal term of it") — a missing phase contributes exactly zero.
24
+ Complex weights as (Re, Im), |·|², a logistic head for the loss, Adam with L2, early-stopped on the inner
25
+ temporal split; F copies of the formula combined by the head, F = 1 being the formula literally.
26
+
27
+ **Populations.** FULL — both natal charts complete and the wedding day known: 6,258 train / 2,635 held out.
28
+ CHARTS — both charts, wedding may be year-only: 9,716 / 7,428. ANY — any row with one phasor: up to 76,081 / 7,428.
29
+ **Plain reference on the same rows** (boosted trees on the two ages at the start, the gap and the start year):
30
+ FULL 0.6371 · CHARTS 0.6083 · ANY 0.6171. Dad's age at the start alone: 0.6264 on FULL.
31
+
32
+ ## 1 · Which terms (all 63 subsets of the six + the composite rungs, FULL, F = 1)
33
+
34
+ | terms | phasors | inner | held out |
35
+ |---|---|---|---|
36
+ | **m + d** (the two wedding-transit terms) — *selected by inner* | 28 | 0.6426 | **0.6304** |
37
+ | a + m + d (Arash's first formula) | 42 | 0.6416 | 0.6258 |
38
+ | a + d | 28 | 0.6280 | 0.6318 |
39
+ | d alone | 14 | 0.6235 | 0.6223 |
40
+ | a alone (synastry) | 14 | 0.5965 | 0.5755 |
41
+ | mn + dn (the natal phases) | 28 | 0.5507 | 0.5235 |
42
+ | tn (the wedding sky alone) | 14 | 0.5201 | 0.4551 |
43
+ | c (the composite alone) | 14 | 0.5069 | 0.4563 |
44
+ | c + tc | 28 | 0.6149 | 0.5828 |
45
+ | a + m + d + mn + dn | 70 | 0.6113 | 0.6098 |
46
+ | a + m + d + mn + dn + tn | 84 | 0.6021 | 0.5931 |
47
+ | a + m + d + c + tc | 70 | 0.6162 | 0.6104 |
48
+ | all eight | 112 | 0.5634 | 0.5740 |
49
+
50
+ Across the 70 configurations the inner split picks m+d; the best held-out anywhere is 0.6318, so the optimism of
51
+ selecting on the test set would have been only +0.0014 here. Every **absolute-phase** term (mn, dn, tn, c) makes
52
+ the model worse out of time; every **difference** term (a, m, d, tc) helps or is neutral.
53
+
54
+ ## 2 · Which bodies (3-term a+m+d, FULL)
55
+
56
+ **One body at a time:** Uranus alone **0.6419** · the three outer 0.6302 · Neptune 0.6260 · Pluto 0.6203 · Chiron
57
+ 0.6118 · Saturn 0.5913 · the nodes 0.5670 · Jupiter 0.5136 · Moon 0.5026 · Lilith 0.5003 · Mars 0.4938 · Venus
58
+ 0.4796 · Mercury 0.4784 · **Sun 0.4728**. Sets: modern10 0.6202 · slow5 0.6204 · classical7 0.5889 · fast5 0.4827.
59
+
60
+ **Dropping one body from all fourteen** changes held-out by −0.0012 (Chiron) to **+0.0062** (either node) — dropping
61
+ any *fast* body improves the model, dropping any *slow* body barely moves it (they are redundant with each other).
62
+
63
+ ## 3 · Invariances that prove what it reads
64
+
65
+ | convention (recomputed through Kerykeion) | 3-term held out | 6-term held out |
66
+ |---|---|---|
67
+ | Lahiri 09:00 local (baseline) | 0.6258 | 0.5931 |
68
+ | Raman · Fagan-Bradley · Krishnamurti | 0.6258 · 0.6258 · 0.6258 | 0.5931 · 0.5931 · 0.5931 |
69
+ | **tropical** | 0.6258 | 0.5941 |
70
+ | birth hour 06:00 · 12:00 · 18:00 local | 0.6258 · 0.6259 · 0.6259 | 0.5931 |
71
+ | 12:00 UT, **place ignored** | 0.6259 | 0.5933 |
72
+ | wedding at 00:00 UT | 0.6260 | 0.5934 |
73
+
74
+ The three-term model is **exactly invariant** to the ayanāṁśa and to the zodiac (a constant offset cancels in a
75
+ difference of two phases), and invariant to four decimals to the birth hour and the birthplace (a common shift of
76
+ both charts moves a slow body by nothing). It therefore cannot be reading anything sidereal, local, or angular.
77
+
78
+ ## 4 · What it reads
79
+
80
+ **The two ages at the wedding, and the age gap, through the slow bodies as clocks.** Uranus moves 4.3°/yr and
81
+ completes a cycle in 84 years, so `θt − θm` for Uranus is mom's age at the wedding, unwrapped for anyone under
82
+ 84; Neptune (2.2°/yr) and Pluto (1.5°/yr) the same at lower resolution; `θm − θd` for the same bodies is the age
83
+ gap. The fitted anatomy says so directly — the largest weights of the 3-term model are `d_pluto 1.27, m_pluto
84
+ 1.16, a_pluto 1.11, d_uranus 0.99, a_neptune 0.90, d_neptune 0.90` — and the controls confirm it:
85
+
86
+ | 3-term, FULL | AUC |
87
+ |---|---|
88
+ | held out | 0.6269 |
89
+ | **held out within 3-year cells of (dad's age, mom's age)** | **0.4955** |
90
+ | within 2-year age-gap bands | 0.5635 |
91
+ | plain reference (two ages + gap + start year, boosted) | 0.6371 |
92
+ | reference + ArtaModel score (combiner fitted on train, out-of-fold) | 0.6361 |
93
+
94
+ Hold the two ages flat and the model is at chance. Add its score to the plain reference and nothing is gained.
95
+ The 6-, 3+composite- and 8-term variants read 0.4765, 0.5080 and 0.5058 age-cell-matched; the reference gains at
96
+ most +0.0024 from any of them (noise).
97
+
98
+ ## 5 · The rest
99
+
100
+ - **Populations** (same formula): a+m+d FULL 0.6258 → CHARTS 0.6047 → ANY 0.6086; the six-term formula FULL
101
+ 0.5931 → CHARTS 0.5719 → **ANY 0.4877** — on ANY, ~50,000 rows carrying only a wedding sky (an era clock) join
102
+ the fit and drag the shared weights toward era, which reverses across the 1900 split.
103
+ - **Angles** (ASC/MC in the synastry/natal terms): 0.6194 vs 0.6258 without — worse.
104
+ - **Harmonics** (phases × h): h=2 0.6284, h=3 0.6203, h=4 0.6069 for a+m+d — a clock survives doubling.
105
+ - **Fields × L2** (3-term): held-out 0.616–0.6365 across 28 settings; the inner split picks F=64, L2=0.01
106
+ → 0.6163 (optimism of picking on the test set would be +0.020). Ten seeds at F=1: 0.6252 ± 0.0030.
107
+ - **Temporal folds inside the training half** rank the ladder the way the held-out set does here (a+m+d 0.63 on
108
+ the folds vs 0.626 held out; six-term 0.58–0.63 vs 0.593) — unlike the tropical stacks of the first edition.
109
+ - **Composite (Davison-style)**: alone at chance (0.4563); with its transit, 0.5828; added to a+m+d, worse (0.6104).
110
+
111
+ ## 6 · Verdict
112
+
113
+ ArtaModel is a well-behaved, fully specified, honestly fitted model — and every point of held-out AUC it earns is
114
+ the two partners' ages at the wedding and the gap between their births, measured by the outer planets as clocks.
115
+ That is why it is invariant to the zodiac, the hour and the place; why Uranus alone equals the whole thing; why
116
+ the fast bodies are pure noise; and why it vanishes when the ages are held flat. Its ceiling on this data is the
117
+ plain reference (0.6371 on FULL), which it does not reach (0.6304 at best, m+d) and does not add to.
118
+
119
+ What would move it: information that is not a function of the three dates — birth **times** (the angles would
120
+ then be real), or a wedding **place** (a real electional lagna). Both are absent from Wikidata for these couples.
121
+
122
+ ## 7 · Ensembles, boosting, and split single-sum models (`artamodel_ensemble.py`)
123
+
124
+ Arash, 2026-08-18: "use ensembles and boosting techniques and split multiple single sum model". FULL population;
125
+ the plain reference on the same rows is 0.6353.
126
+
127
+ | construction | 3-term | 6-term |
128
+ |---|---|---|
129
+ | single ArtaModel F=1 | 0.6251 | 0.5820 |
130
+ | BAG, 25 bootstraps rank-averaged | 0.6339 | 0.6031 |
131
+ | BOOST, single-sum fields on residuals | 0.6318 | 0.5933–0.5969 |
132
+ | SPLIT per body (14 single sums), linear head | 0.6339 | 0.6251 |
133
+ | SPLIT per term / per phasor, linear head | 0.6291 / 0.6297 | 0.6195 / 0.6070 |
134
+ | SPLIT → LightGBM on the intensities | 0.6220–0.6293 | 0.6097–0.6269 |
135
+ | **BOOST over SPLIT, per phasor** | **0.6373** | **0.6388** |
136
+
137
+ Splitting rescues the six-term formula (each absolute-phase term in its own sum can be weighted down); boosting
138
+ over the split sums reaches the reference but does not cross it; the age-cell-matched control stays at 0.50–0.52
139
+ for every construction — better instruments for the same two quantities. Inner-selected picks: 3-term BOOST
140
+ 0.6318, 6-term BOOST-over-SPLIT-per-body 0.6293 (the top held-out numbers carry about +0.007 of optimism).
README.md ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: cc0-1.0
3
+ language: [en]
4
+ tags: [astrology, sidereal, jyotisha, marriage, tabular, phase-model, artaquest]
5
+ datasets: [artaquest-foundation/artamatch-sidereal]
6
+ metrics: [roc_auc]
7
+ library_name: numpy
8
+ ---
9
+
10
+ # ArtaModel
11
+
12
+ **A sidereal phase model of a marriage**, named by Arash Ashrafnejad (ArtaQuest Foundation, 2026-08-18). Three dates
13
+ and two places in — his birth and birthplace, hers, and the wedding date — one probability out: did the marriage
14
+ last thirty years?
15
+
16
+ ```
17
+ y = | b + Σᵢ aᵢ ·e^{i(θmᵢ − θdᵢ)} mom's longitude minus dad's (synastry)
18
+ + mᵢ ·e^{i(θtᵢ − θmᵢ)} the wedding sky minus mom's chart (transit to mom)
19
+ + dᵢ ·e^{i(θtᵢ − θdᵢ)} the wedding sky minus dad's chart (transit to dad)
20
+ + mnᵢ·e^{i θmᵢ} mom's own natal longitude
21
+ + dnᵢ·e^{i θdᵢ} dad's own natal longitude
22
+ + tnᵢ·e^{i θtᵢ} |² the wedding sky itself
23
+ ```
24
+
25
+ for each of fourteen bodies *i* (Sun, Moon, Mercury, Venus, Mars, Jupiter, Saturn, Uranus, Neptune, Pluto, Rāhu,
26
+ Ketu, Chiron, Lilith). θ are **sidereal longitudes (Lahiri)** from Kerykeion (Swiss Ephemeris): the births cast at
27
+ **09:00 local time at the birthplace** (nobody's birth time is recorded — this is the dataset's convention), the
28
+ wedding at 12:00 UT. **A term exists only when both of its phases exist**: an unknown wedding day drops the wedding
29
+ terms, an unknown birth drops that partner's terms; a missing phase contributes exactly zero.
30
+
31
+ ## The deployed model, term by term
32
+
33
+ The deployed model is **gradient boosting over split single-sum fields**: each stage is one field
34
+ `|bₖ + wₖ·e^{iφ}|²` on **one** phasor φ, chosen greedily at that stage as the phasor that best explains the
35
+ current residual (so all 84 phasors of all six terms compete at every stage), and added to the logit as
36
+ `stepₖ·(αₖ·u + cₖ)`. Fitted on **all the data — train and test rows with both natal charts, 16,802
37
+ couples** — for 31 stages (the number the train-only fit chose on its inner temporal split).
38
+
39
+ Of the 84 phasors offered, the boosting chose **6**. Every one of them is an outer-planet clock:
40
+
41
+ | phasor | body | term | stages | contribution to the logit swing | phase at which the field peaks |
42
+ |---|---|---|---|---|---|
43
+ | `a_uranus` | Uranus | a | 12 | 0.221 | 193° |
44
+ | `d_pluto` | Pluto | d | 5 | 0.170 | 295° |
45
+ | `d_neptune` | Neptune | d | 3 | 0.096 | 200° |
46
+ | `d_uranus` | Uranus | d | 4 | 0.056 | 88° |
47
+ | `m_pluto` | Pluto | m | 3 | 0.019 | 134° |
48
+ | `m_saturn` | Saturn | m | 4 | 0.001 | 103° |
49
+
50
+ Never chosen, at any stage:
51
+ - **a** (a·e^{i(θm−θd)}): 13 bodies never chosen — sun, moon, mercury, venus, mars, jupiter, saturn, neptune, pluto, true_node, true_south_node, chiron, mean_lilith
52
+ - **m** (m·e^{i(θt−θm)}): 12 bodies never chosen — sun, moon, mercury, venus, mars, jupiter, uranus, neptune, true_node, true_south_node, chiron, mean_lilith
53
+ - **d** (d·e^{i(θt−θd)}): 11 bodies never chosen — sun, moon, mercury, venus, mars, jupiter, saturn, true_node, true_south_node, chiron, mean_lilith
54
+ - **mn** (mn·e^{iθm}): 14 bodies never chosen — sun, moon, mercury, venus, mars, jupiter, saturn, uranus, neptune, pluto, true_node, true_south_node, chiron, mean_lilith
55
+ - **dn** (dn·e^{iθd}): 14 bodies never chosen — sun, moon, mercury, venus, mars, jupiter, saturn, uranus, neptune, pluto, true_node, true_south_node, chiron, mean_lilith
56
+ - **tn** (tn·e^{iθt}): 14 bodies never chosen — sun, moon, mercury, venus, mars, jupiter, saturn, uranus, neptune, pluto, true_node, true_south_node, chiron, mean_lilith
57
+
58
+ **Read plainly:** `a_uranus` is the age gap between the two births measured by Uranus (4.3°/yr); `d_pluto`,
59
+ `d_neptune`, `d_uranus` are the groom's age at the wedding measured by Pluto, Neptune and Uranus; `m_pluto`,
60
+ `m_saturn` are the bride's. The model uses no natal phase, no wedding-sky phase, and no fast body — the study
61
+ (`ARTAMODEL.md`) shows why: those terms are era clocks or noise, and they make the model worse out of time.
62
+
63
+ ## What it scores, honestly
64
+
65
+ | on couples born after 1900 (temporal hold-out) | AUC |
66
+ |---|---|
67
+ | this construction, fitted on train alone (its inner temporal split chose 31 stages) | inner 0.6320 · **held-out ≈ 0.62–0.64** |
68
+ | the plain columns — two ages at the wedding, the gap, the start year (LightGBM) | 0.6189–0.6371 depending on the row population |
69
+ | the same model with the two ages held flat (AUC within 3-year age cells) | **≈ 0.50** |
70
+
71
+ The held-out AUC of ArtaModel is real, and every point of it is the two partners' ages at the wedding and the gap
72
+ between their births, read through the outer planets as clocks. It is exactly invariant to the ayanāṁśa (a
73
+ constant offset cancels in a phase difference), to the birth hour and to the birthplace; Uranus alone equals the
74
+ whole model; the Sun alone scores 0.47; and it adds nothing to a plain model of the ages. This card says so because
75
+ the study measured it, from every angle, on fixed populations — see `ARTAMODEL.md` and `artamodel_study.json`.
76
+
77
+ ## Use
78
+
79
+ ```python
80
+ from artamodel_score import predict # needs: numpy, kerykeion, timezonefinder
81
+ r = predict("1936-08-04", 37.943, 23.647, # dad: dob, lat, lon
82
+ "1924-05-14", 37.727, 26.909, # mom
83
+ "1968-06-15") # wedding date (YYYY-01-01 = year only -> wedding terms dropped)
84
+ r["probability"], r["terms"] # the probability, and the stage-by-stage account
85
+ ```
86
+
87
+ `artamodel_deployed.json` holds every stage's weights; `artamodel.py` / `artamodel_ensemble.py` /
88
+ `artamodel_deploy.py` are the fit; `kerykeion_phases.py` the phase extraction. Data:
89
+ [artaquest-foundation/artamatch-sidereal](https://www.kaggle.com/datasets/artaquest-foundation/artamatch-sidereal);
90
+ competition: [artamatch-sidereal](https://www.kaggle.com/competitions/artamatch-sidereal). CC0.
artamodel.py ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ artamodel.py — ArtaModel, canonical implementation.
3
+
4
+ y = | b + Σ_i a_i e^{i(θm_i − θd_i)} synastry, mom − dad
5
+ + m_i e^{i(θt_i − θm_i)} the wedding sky transiting mom
6
+ + d_i e^{i(θt_i − θd_i)} the wedding sky transiting dad
7
+ + mn_i e^{i θm_i} mom's own natal phase
8
+ + dn_i e^{i θd_i} dad's own natal phase
9
+ + tn_i e^{i θt_i} the wedding sky's own phase
10
+ + c_i e^{i θc_i} the COMPOSITE: the shorter-arc midpoint of mom's and dad's natal
11
+ longitudes (Arash, 2026-08-18: "add the mid angle of natals as
12
+ term, like Davison compatibility")
13
+ + tc_i e^{i(θt_i − θc_i)} |² the wedding sky transiting the composite
14
+
15
+ θ are sidereal longitudes (Kerykeion, Lahiri unless told otherwise): dad and mom at 09:00 local at each birthplace,
16
+ θt the marriage date at 12:00 UT. Every complex weight is held as (Re, Im); the model is a complex-linear map over
17
+ the phasors followed by |·|², a logistic head turns the standardised intensity into a probability for the loss,
18
+ and AUC reads the intensity's ranking. F fields = F copies of the formula combined by the head; F = 1 is the
19
+ formula literally.
20
+
21
+ THE PRESENCE RULE (Arash, 2026-08-18): a term exists only when both of its phases exist. "If wedding is not known,
22
+ drop the last two terms"; "if dob of either is not known, drop the natal term of it". A missing phase is NaN in the
23
+ phase matrix and contributes exactly zero to the sum (cos = sin = 0), so one parameter set serves every row and a
24
+ row is usable as long as it has at least one phasor.
25
+
26
+ TERMS are selectable by name -- "a", "m", "d", "mn", "dn", "tn" -- and BODIES by name, so the study can ablate.
27
+ """
28
+ import os
29
+ import sys
30
+
31
+ import numpy as np
32
+
33
+ HERE = os.path.dirname(os.path.abspath(__file__))
34
+ sys.path.insert(0, os.path.join(HERE, "..", "coherent"))
35
+ from coherent_fit import Coherent, auc # noqa: E402
36
+
37
+ TERMS = ("a", "m", "d", "mn", "dn", "tn")
38
+ TERMS8 = TERMS + ("c", "tc")
39
+ BODIES14 = ["sun", "moon", "mercury", "venus", "mars", "jupiter", "saturn", "uranus", "neptune", "pluto",
40
+ "true_node", "true_south_node", "chiron", "mean_lilith"]
41
+ ANGLES = ["ascendant", "medium_coeli"]
42
+ GROUPS = {"luminaries": ["sun", "moon"], "inner": ["mercury", "venus", "mars"], "social": ["jupiter", "saturn"],
43
+ "outer": ["uranus", "neptune", "pluto"], "nodes": ["true_node", "true_south_node"],
44
+ "points": ["chiron", "mean_lilith"], "angles": ANGLES}
45
+
46
+
47
+ def composite(D, M):
48
+ """The shorter-arc midpoint of two longitudes, in degrees on [0, 360): dad + half the wrapped difference.
49
+ (The naive (θm+θd)/2 is ambiguous by 180 degrees whenever the pair straddles 0.)"""
50
+ diff = (M - D + 180.0) % 360.0 - 180.0
51
+ return (D + diff / 2.0) % 360.0
52
+
53
+
54
+ def phase_matrix(D, M, W, all_bodies, bodies, terms, angles_in_natal=False):
55
+ """(n, K) phases in degrees (NaN = the term does not exist for that row) and K labels."""
56
+ col = {b: j for j, b in enumerate(all_bodies)}
57
+ P, lab = [], []
58
+ use = list(bodies) + (ANGLES if angles_in_natal else [])
59
+ for t in terms:
60
+ for b in use:
61
+ j = col[b]
62
+ if t == "a":
63
+ P.append(M[:, j] - D[:, j])
64
+ elif t == "c":
65
+ P.append(composite(D[:, j], M[:, j]))
66
+ elif t == "tc":
67
+ if b in ANGLES: continue
68
+ P.append(W[:, j] - composite(D[:, j], M[:, j]))
69
+ elif t == "m":
70
+ if b in ANGLES: continue
71
+ P.append(W[:, j] - M[:, j])
72
+ elif t == "d":
73
+ if b in ANGLES: continue
74
+ P.append(W[:, j] - D[:, j])
75
+ elif t == "mn":
76
+ P.append(M[:, j])
77
+ elif t == "dn":
78
+ P.append(D[:, j])
79
+ elif t == "tn":
80
+ if b in ANGLES: continue
81
+ P.append(W[:, j])
82
+ else:
83
+ raise ValueError(t)
84
+ lab.append(f"{t}_{b}")
85
+ return (np.column_stack(P) if P else np.zeros((len(D), 0))), lab
86
+
87
+
88
+ class ArtaModel:
89
+ """One ArtaModel: a term set, a body set, F fields, and the fitted weights."""
90
+
91
+ def __init__(self, terms=TERMS, bodies=BODIES14, F=1, l2=1e-3, angles_in_natal=False, seed=0):
92
+ self.terms, self.bodies, self.F, self.l2, self.angles, self.seed = tuple(terms), list(bodies), F, l2, angles_in_natal, seed
93
+ self.model = None; self.labels = None; self.inner_auc = None; self.epochs = None
94
+
95
+ def _cs(self, P):
96
+ rad = np.pi / 180.0
97
+ return np.nan_to_num(np.cos(P * rad)), np.nan_to_num(np.sin(P * rad))
98
+
99
+ def fit(self, P, y, inner, lr=0.01, epochs=150, patience=15, batch=None):
100
+ """Early-stopped on `inner` (a boolean mask: the inner temporal validation rows). Returns self.
101
+
102
+ The batch adapts to the population: at least sixteen gradient steps per epoch. A fixed 2,048 gave a
103
+ 300-row self-test one step per epoch and 80 steps in all, and the planted phasor was not recovered."""
104
+ C, S = self._cs(P)
105
+ m = Coherent(C.shape[1], self.F, seed=self.seed)
106
+ rng = np.random.default_rng(1000 + self.seed)
107
+ idx = np.where(~inner)[0]
108
+ if batch is None:
109
+ batch = int(min(1024, max(32, len(idx) // 16)))
110
+ best, state, bad, best_ep = -1.0, None, 0, 0
111
+ for ep in range(epochs):
112
+ rng.shuffle(idx)
113
+ for s0 in range(0, len(idx), batch):
114
+ b = idx[s0:s0 + batch]
115
+ if len(b) >= 32:
116
+ m.step(C[b], S[b], y[b].astype(float), lr, self.l2)
117
+ a = auc(y[inner], m.logit(C[inner], S[inner])[0]) if inner.any() else 0.5
118
+ if a > best + 1e-5:
119
+ best, bad, best_ep = a, 0, ep
120
+ state = (m.A1.copy(), m.A2.copy(), m.br.copy(), m.bi.copy(), m.w.copy(), m.c, m.mu.copy(), m.sd.copy())
121
+ else:
122
+ bad += 1
123
+ if bad >= patience:
124
+ break
125
+ m.A1, m.A2, m.br, m.bi, m.w, m.c, m.mu, m.sd = state
126
+ self.model, self.inner_auc, self.epochs = m, best, best_ep
127
+ return self
128
+
129
+ def score(self, P):
130
+ C, S = self._cs(P)
131
+ return self.model.logit(C, S)[0]
132
+
133
+ def intensity(self, P):
134
+ """|b + Σ w_k z_k|² per field, before the head: (n, F)."""
135
+ C, S = self._cs(P)
136
+ return self.model.fields(C, S)[2]
137
+
138
+ def weights(self, labels):
139
+ """The fitted complex weights of field 0: {label: (modulus, phase_deg)} plus the bias and head weight."""
140
+ m = self.model
141
+ w = m.A1[0] - 1j * m.A2[0] # A1 = Re w, A2 = -Im w
142
+ out = {lab: (float(abs(w[k])), float(np.degrees(np.angle(w[k])))) for k, lab in enumerate(labels)}
143
+ out["_bias"] = (float(abs(m.br[0] + 1j * m.bi[0])), float(np.degrees(np.angle(m.br[0] + 1j * m.bi[0]))))
144
+ out["_head_w"] = (float(m.w[0]), 0.0)
145
+ return out
146
+
147
+
148
+ def fit_ensemble(P, y, inner, Pte, seeds=3, **kw):
149
+ """Mean held-out score over seeds, and the mean inner AUC."""
150
+ outs, ivs = [], []
151
+ for s in range(seeds):
152
+ am = ArtaModel(seed=s, **kw).fit(P, y, inner)
153
+ outs.append(am.score(Pte)); ivs.append(am.inner_auc)
154
+ return float(np.mean(ivs)), np.mean(outs, 0)
155
+
156
+
157
+ if __name__ == "__main__":
158
+ rng = np.random.default_rng(0)
159
+ n, B = 400, len(BODIES14)
160
+ D = rng.uniform(0, 360, (n, B)); M = rng.uniform(0, 360, (n, B)); W = rng.uniform(0, 360, (n, B))
161
+ P, lab = phase_matrix(D, M, W, BODIES14, BODIES14, TERMS)
162
+ assert P.shape == (n, 6 * B) and len(lab) == 6 * B
163
+ # a planted signal in one phasor must be recovered
164
+ y = (np.cos(np.radians(M[:, 1] - D[:, 1])) + 0.3 * rng.normal(size=n) > 0).astype(int)
165
+ inner = np.arange(n) >= 300
166
+ am = ArtaModel(F=1).fit(P, y, inner)
167
+ w = am.weights(lab)
168
+ top = max((k for k in w if not k.startswith("_")), key=lambda k: w[k][0])
169
+ print(f" {6*B} phasors · planted signal on a_moon · largest fitted weight: {top} (|w|={w[top][0]:.3f}) · inner AUC {am.inner_auc:.3f}")
170
+ assert top == "a_moon", "the fit did not recover the planted phasor"
171
+ # a NaN phase contributes nothing: scores identical whether the column is NaN or absent
172
+ P2 = P.copy(); P2[:, 5] = np.nan
173
+ P3, _ = phase_matrix(D, M, W, BODIES14, [b for b in BODIES14 if b != BODIES14[5]] + [BODIES14[5]], TERMS)
174
+ print(" presence rule: a NaN phase zeroes its phasor (cos=sin=0)")
artamodel_deploy.py ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ artamodel_deploy.py — the deployable ArtaModel: 6-term, boosted over split single-sum fields (one phasor per
3
+ field), every stage recorded, serialised to JSON, and a scorer that needs only numpy.
4
+
5
+ Two fits are made:
6
+ LEADERBOARD fitted on the training rows that have both natal charts (9,553), applied to the 7,249 test rows;
7
+ nothing from the answer key -- this is what goes to Kaggle.
8
+ DEPLOYED the same construction fitted on train + test (Arash: "the deployed model should be trained on all
9
+ the data"), 9,553 + 7,249 rows; this is what goes to Hugging Face and to prod. It cannot be scored
10
+ on the held-out set, because the held-out set is inside it -- the leaderboard fit is its estimate.
11
+
12
+ THE MODEL, term by term (see explain()): for each of fourteen bodies i and each of six terms,
13
+ a_i e^{i(θm_i − θd_i)} mom's longitude minus dad's (synastry)
14
+ m_i e^{i(θt_i − θm_i)} the wedding-day longitude minus mom's (the wedding transiting mom)
15
+ d_i e^{i(θt_i − θd_i)} the wedding-day longitude minus dad's (the wedding transiting dad)
16
+ mn_i e^{i θm_i} mom's natal longitude
17
+ dn_i e^{i θd_i} dad's natal longitude
18
+ tn_i e^{i θt_i} the wedding-day longitude
19
+ θ sidereal (Lahiri) from Kerykeion, births at 09:00 local, the wedding at 12:00 UT. Each stage k of the boosted
20
+ model is ONE single-sum field u_k = |b_k + w_k z_j|² on ONE phasor j, chosen GREEDILY at that stage as the phasor
21
+ that best explains the current residual (so all 84 phasors of all six terms compete every time), added as
22
+ step_k · (α_k · u_k + c_k) to the logit; a term missing for a row (unknown wedding, unknown birth) contributes
23
+ zero. The score is the logit after the last stage; the probability is its sigmoid.
24
+ """
25
+ import json
26
+ import os
27
+ import sys
28
+
29
+ import numpy as np
30
+ import pandas as pd
31
+
32
+ HERE = os.path.dirname(os.path.abspath(__file__))
33
+ sys.path.insert(0, HERE)
34
+ from artamodel import BODIES14, TERMS, phase_matrix # noqa: E402
35
+ from artamodel_ensemble import Field, auc, cs # noqa: E402
36
+
37
+ PH = os.environ.get("AQ_PHASES", "/tmp/aq3feat/phases.npz")
38
+ OUT = os.environ.get("AQ_OUT", "/tmp/aq3sub")
39
+ os.makedirs(OUT, exist_ok=True)
40
+ TERM_TEXT = {"a": "mom's longitude minus dad's — the synastry angle between the two natal charts",
41
+ "m": "the wedding-day longitude minus mom's natal longitude — the wedding sky transiting mom",
42
+ "d": "the wedding-day longitude minus dad's natal longitude — the wedding sky transiting dad",
43
+ "mn": "mom's own natal longitude", "dn": "dad's own natal longitude",
44
+ "tn": "the wedding-day longitude itself"}
45
+ BODY_TEXT = {"sun": "Sun", "moon": "Moon", "mercury": "Mercury", "venus": "Venus", "mars": "Mars", "jupiter": "Jupiter",
46
+ "saturn": "Saturn", "uranus": "Uranus", "neptune": "Neptune", "pluto": "Pluto", "true_node": "Rāhu (true north node)",
47
+ "true_south_node": "Ketu (true south node)", "chiron": "Chiron", "mean_lilith": "Lilith (mean lunar apogee)"}
48
+
49
+
50
+ def pick_phasor(C, S, r, used_counts, K):
51
+ """GREEDY stage selection: the phasor whose (cos, sin) best explain the current residual by least squares --
52
+ the two-parameter proxy of the single-sum field -- so every term competes at every stage. A fixed cycle
53
+ (stage k -> phasor k) with early stopping at ~37 stages had never even offered the natal and wedding-sky
54
+ phasors, which sit last in the label order; that is not a six-term model."""
55
+ best_j, best_r2 = 0, -1.0
56
+ rc = r - r.mean()
57
+ for j in range(K):
58
+ X = np.column_stack([C[:, j], S[:, j]])
59
+ Xc = X - X.mean(0)
60
+ try:
61
+ beta, *_ = np.linalg.lstsq(Xc, rc, rcond=None)
62
+ except Exception:
63
+ continue
64
+ r2 = 1.0 - float(((rc - Xc @ beta) ** 2).sum()) / max(1e-12, float((rc ** 2).sum()))
65
+ if r2 > best_r2:
66
+ best_r2, best_j = r2, j
67
+ return best_j
68
+
69
+
70
+ def boost_recorded(P, y, inner, stages, nu=0.1, seed=0):
71
+ """Gradient boosting over split single-sum fields, ONE phasor per stage chosen greedily against the current
72
+ residual, every stage recorded."""
73
+ C, S = cs(P); K = C.shape[1]; fit = ~inner
74
+ p0 = np.clip(y[fit].mean(), 1e-3, 1 - 1e-3); F0 = float(np.log(p0 / (1 - p0)))
75
+ F = np.full(len(y), F0)
76
+ rec = []; best, best_n, bad = -1.0, 0, 0
77
+ used_counts = np.zeros(K, int)
78
+ for k in range(stages):
79
+ p = 1 / (1 + np.exp(-F)); r = y - p
80
+ j = pick_phasor(C[fit], S[fit], r[fit], used_counts, K); used_counts[j] += 1
81
+ mask = np.zeros(K); mask[j] = 1.0
82
+ f = Field(K, seed * 1000 + k, mask).fit(C[fit], S[fit], r[fit], C[inner], S[inner], r[inner]) if inner.any() \
83
+ else Field(K, seed * 1000 + k, mask).fit(C[fit], S[fit], r[fit], C[fit][:64], S[fit][:64], r[fit][:64])
84
+ h = f.predict(C, S)
85
+ best_g, best_loss = 0.0, np.inf
86
+ for gam in (0.25, 0.5, 1.0, 2.0, 4.0):
87
+ Fx = F[fit] + nu * gam * h[fit]
88
+ loss = float(np.mean(np.logaddexp(0, -Fx * (2 * y[fit] - 1))))
89
+ if loss < best_loss:
90
+ best_loss, best_g = loss, gam
91
+ F = F + nu * best_g * h
92
+ rec.append({"stage": k + 1, "phasor": int(j), "step": nu * best_g, "alpha": float(f.alpha), "c": float(f.c),
93
+ "w_re": float(f.A1[j]), "w_im": float(-f.A2[j]), "b_re": float(f.br), "b_im": float(f.bi)})
94
+ if inner.any():
95
+ a = auc(y[inner], F[inner])
96
+ if a > best + 1e-5:
97
+ best, best_n, bad = a, k + 1, 0
98
+ else:
99
+ bad += 1
100
+ if bad >= 10:
101
+ break
102
+ n_keep = best_n if inner.any() else len(rec)
103
+ return {"F0": F0, "stages": rec[:n_keep], "inner_auc": best if inner.any() else None}
104
+
105
+
106
+ def score(model, P):
107
+ """The logit of the deployed model for phase matrix P (degrees; NaN = the term is absent). numpy only."""
108
+ rad = np.pi / 180.0
109
+ C, S = np.nan_to_num(np.cos(P * rad)), np.nan_to_num(np.sin(P * rad))
110
+ F = np.full(len(P), model["F0"])
111
+ for st in model["stages"]:
112
+ j = st["phasor"]
113
+ # z = e^{iφ}; w z = (wr + i wi)(C + i S) = (wr C − wi S) + i(wr S + wi C)
114
+ Zr = st["w_re"] * C[:, j] - st["w_im"] * S[:, j] + st["b_re"]
115
+ Zi = st["w_re"] * S[:, j] + st["w_im"] * C[:, j] + st["b_im"]
116
+ u = Zr * Zr + Zi * Zi
117
+ F = F + st["step"] * (st["alpha"] * u + st["c"])
118
+ return F
119
+
120
+
121
+ def explain(model, labels):
122
+ """Term-by-term account: every phasor's meaning, whether the deployed model uses it, and with what weight.
123
+ The 'contribution' is Σ_stages step·|alpha|·|w|², the scale of the phasor's swing in the logit."""
124
+ rows = {}
125
+ for st in model["stages"]:
126
+ lab = labels[st["phasor"]]; term, body = lab.split("_", 1)
127
+ w = complex(st["w_re"], st["w_im"]); b = complex(st["b_re"], st["b_im"])
128
+ r = rows.setdefault(lab, {"phasor": lab, "term": term, "body": BODY_TEXT.get(body, body),
129
+ "meaning": f"{TERM_TEXT[term]}, for {BODY_TEXT.get(body, body)}",
130
+ "stages": 0, "contribution": 0.0, "phase_deg": None})
131
+ r["stages"] += 1
132
+ r["contribution"] += abs(st["step"] * st["alpha"]) * abs(w) ** 2
133
+ # the angle at which the field peaks: |b + w e^{iφ}|² is largest when arg(w) + φ = arg(b), i.e. φ* = arg(b) − arg(w)
134
+ r["phase_deg"] = float(np.degrees(np.angle(b) - np.angle(w)) % 360.0)
135
+ used = sorted(rows.values(), key=lambda r: -r["contribution"])
136
+ unused = [lab for lab in labels if lab not in rows]
137
+ return used, unused
138
+
139
+
140
+ def main():
141
+ Z = np.load(PH, allow_pickle=True)
142
+ bodies = list(Z["bodies"]); ids = Z["id_test"]; y = Z["y_train"].astype(np.int64)
143
+ Dtr, Mtr, Wtr = Z["theta_dad_train"], Z["theta_mom_train"], Z["theta_wed_train"]
144
+ Dte, Mte, Wte = Z["theta_dad_test"], Z["theta_mom_test"], Z["theta_wed_test"]
145
+ ptr, pte, pn = Z["plain_train"], Z["plain_test"], list(Z["plain_names"])
146
+ later = Z["yr_train"].astype(int).max(1)
147
+ j1 = ptr[:, pn.index("start_is_jan1")] == 1.0; j1e = pte[:, pn.index("start_is_jan1")] == 1.0
148
+ Wtr = Wtr.copy(); Wte = Wte.copy(); Wtr[j1] = np.nan; Wte[j1e] = np.nan
149
+ B = [bodies.index(b) for b in BODIES14]
150
+ charts = np.isfinite(Dtr[:, B]).all(1) & np.isfinite(Mtr[:, B]).all(1)
151
+ P, labels = phase_matrix(Dtr, Mtr, Wtr, bodies, BODIES14, TERMS); Pe, _ = phase_matrix(Dte, Mte, Wte, bodies, BODIES14, TERMS)
152
+ K = len(labels)
153
+ # LEADERBOARD fit
154
+ lat = later[charts]; inner = lat > np.quantile(lat, 0.85)
155
+ lb = boost_recorded(P[charts], y[charts], inner, stages=4 * K, nu=0.1)
156
+ s_lb = score(lb, Pe)
157
+ print(f" leaderboard fit: {int(charts.sum()):,} rows · {len(lb['stages'])} stages kept · inner {lb['inner_auc']:.4f}")
158
+ from scipy.stats import rankdata
159
+ r01 = lambda v: (rankdata(v) - 1) / max(1.0, len(v) - 1)
160
+ pd.DataFrame({"id": ids, "lasted_30_years": r01(s_lb)}).to_csv(f"{OUT}/submission_artamodel6.csv", index=False)
161
+ json.dump({**lb, "labels": labels, "fitted_on": "train rows with both natal charts", "n_rows": int(charts.sum())},
162
+ open(f"{OUT}/artamodel_leaderboard.json", "w"), indent=1)
163
+ # DEPLOYED fit: train + test, the number of stages the leaderboard fit chose (no inner split possible)
164
+ sol = pd.read_csv(os.environ.get("AQ_SOL", "/tmp/aq3comp/solution.csv")).set_index("id")
165
+ yte = sol.loc[ids, "lasted_30_years"].to_numpy().astype(int)
166
+ Pall = np.vstack([P[charts], Pe]); yall = np.concatenate([y[charts], yte])
167
+ dep = boost_recorded(Pall, yall, np.zeros(len(yall), bool), stages=len(lb["stages"]), nu=0.1)
168
+ dep.update({"labels": labels, "terms": list(TERMS), "bodies": BODIES14, "fitted_on": "train + test rows with both natal charts",
169
+ "n_rows": int(len(yall)), "leaderboard_estimate": {"inner_auc": lb["inner_auc"], "note": "the same construction fitted on train alone; its held-out AUC is what the leaderboard reports"},
170
+ "phase_convention": {"zodiac": "sidereal, Lahiri", "engine": "Kerykeion 5.12.9 (Swiss Ephemeris)", "birth_time": "09:00 local at the birthplace",
171
+ "wedding_time": "12:00 UT", "presence_rule": "a term exists only when both of its phases exist; a missing phase contributes zero"}})
172
+ used, unused = explain(dep, labels)
173
+ dep["explanation"] = {"used": used, "unused": unused}
174
+ json.dump(dep, open(f"{OUT}/artamodel_deployed.json", "w"), indent=1)
175
+ print(f" deployed fit: {len(yall):,} rows · {len(dep['stages'])} stages · {len(used)} phasors carry weight, {len(unused)} unused")
176
+ print(f" top phasors by contribution:")
177
+ for r in used[:10]:
178
+ print(f" {r['phasor']:<20} {r['contribution']:8.3f} peak at φ={r['phase_deg']:6.1f}° {r['meaning'][:70]}")
179
+
180
+
181
+ if __name__ == "__main__":
182
+ main()
artamodel_deployed.json ADDED
@@ -0,0 +1,608 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "F0": -0.2675045808335571,
3
+ "stages": [
4
+ {
5
+ "stage": 1,
6
+ "phasor": 35,
7
+ "step": 0.4,
8
+ "alpha": 0.2962068264262272,
9
+ "c": -0.3250943780296887,
10
+ "w_re": 0.15543886682420152,
11
+ "w_im": -0.29112648554866294,
12
+ "b_re": 0.8612165544831112,
13
+ "b_im": 0.37100357183798066
14
+ },
15
+ {
16
+ "stage": 2,
17
+ "phasor": 36,
18
+ "step": 0.4,
19
+ "alpha": 0.36952438046659153,
20
+ "c": -0.1390818933772614,
21
+ "w_re": -0.3206126549000757,
22
+ "w_im": -0.3732272462576281,
23
+ "b_re": -0.3873964806923474,
24
+ "b_im": -0.28133755975435937
25
+ },
26
+ {
27
+ "stage": 3,
28
+ "phasor": 7,
29
+ "step": 0.4,
30
+ "alpha": 0.511645759863731,
31
+ "c": -0.4866446214859608,
32
+ "w_re": 0.30976678844570504,
33
+ "w_im": 0.17084942474953138,
34
+ "b_re": 0.5722916747341479,
35
+ "b_im": 0.37448527919398444
36
+ },
37
+ {
38
+ "stage": 4,
39
+ "phasor": 36,
40
+ "step": 0.4,
41
+ "alpha": 0.28539553490771224,
42
+ "c": -0.10925294880528959,
43
+ "w_re": -0.4637512051740837,
44
+ "w_im": 0.15968357227678315,
45
+ "b_re": -0.40333485010586123,
46
+ "b_im": 0.20652134764416352
47
+ },
48
+ {
49
+ "stage": 5,
50
+ "phasor": 7,
51
+ "step": 0.4,
52
+ "alpha": 0.34873530136954234,
53
+ "c": -0.41548504275199516,
54
+ "w_re": 0.40474051239376696,
55
+ "w_im": 0.27445452735416326,
56
+ "b_re": 0.5102887827791266,
57
+ "b_im": 0.4315683113812937
58
+ },
59
+ {
60
+ "stage": 6,
61
+ "phasor": 36,
62
+ "step": 0.4,
63
+ "alpha": -0.241037762135293,
64
+ "c": 0.13215887217515404,
65
+ "w_re": 0.21215114680381456,
66
+ "w_im": -0.5421753529848402,
67
+ "b_re": -0.5358308970672356,
68
+ "b_im": 0.6130860946629627
69
+ },
70
+ {
71
+ "stage": 7,
72
+ "phasor": 7,
73
+ "step": 0.4,
74
+ "alpha": -0.3496820407898508,
75
+ "c": 0.06137999056834759,
76
+ "w_re": 0.1380069521851862,
77
+ "w_im": -0.4356930091878805,
78
+ "b_re": -0.4051369005715042,
79
+ "b_im": 0.5966729704744487
80
+ },
81
+ {
82
+ "stage": 8,
83
+ "phasor": 37,
84
+ "step": 0.4,
85
+ "alpha": 0.6073940674491038,
86
+ "c": -0.25189132057139996,
87
+ "w_re": 0.04233981935566459,
88
+ "w_im": 0.5301270057768898,
89
+ "b_re": 0.5919403309774376,
90
+ "b_im": 0.1527753550072731
91
+ },
92
+ {
93
+ "stage": 9,
94
+ "phasor": 35,
95
+ "step": 0.4,
96
+ "alpha": 0.24588611736940158,
97
+ "c": -0.19626978063935527,
98
+ "w_re": 0.23233638472479226,
99
+ "w_im": -0.1560666829354965,
100
+ "b_re": 0.48646135225126724,
101
+ "b_im": 0.6372359640426333
102
+ },
103
+ {
104
+ "stage": 10,
105
+ "phasor": 7,
106
+ "step": 0.4,
107
+ "alpha": 0.2931814249521459,
108
+ "c": -0.3100593466978247,
109
+ "w_re": -0.38412437288784573,
110
+ "w_im": -0.19958758032052099,
111
+ "b_re": -0.5400458194422211,
112
+ "b_im": -0.3722948214410381
113
+ },
114
+ {
115
+ "stage": 11,
116
+ "phasor": 37,
117
+ "step": 0.4,
118
+ "alpha": -0.4582008834794563,
119
+ "c": 0.20831408669194976,
120
+ "w_re": 0.21500649446516568,
121
+ "w_im": 0.3452875759252673,
122
+ "b_re": -0.5950527460559584,
123
+ "b_im": -0.36050393980345646
124
+ },
125
+ {
126
+ "stage": 12,
127
+ "phasor": 7,
128
+ "step": 0.4,
129
+ "alpha": -0.3407943339757083,
130
+ "c": 0.0536989476773817,
131
+ "w_re": 0.42368713327013,
132
+ "w_im": 0.23017860913096239,
133
+ "b_re": -0.4619022763623476,
134
+ "b_im": -0.3595962872606437
135
+ },
136
+ {
137
+ "stage": 13,
138
+ "phasor": 35,
139
+ "step": 0.4,
140
+ "alpha": -0.39081012792802494,
141
+ "c": 0.05701871153903491,
142
+ "w_re": -0.2619927832133936,
143
+ "w_im": 0.37777763453428553,
144
+ "b_re": 0.19643086084436306,
145
+ "b_im": 0.39600846374366316
146
+ },
147
+ {
148
+ "stage": 14,
149
+ "phasor": 7,
150
+ "step": 0.4,
151
+ "alpha": 0.3367529793612754,
152
+ "c": -0.2391218389782931,
153
+ "w_re": -0.22962807417260392,
154
+ "w_im": -0.23915132001253248,
155
+ "b_re": -0.3283221604241518,
156
+ "b_im": -0.44641895137626997
157
+ },
158
+ {
159
+ "stage": 15,
160
+ "phasor": 37,
161
+ "step": 0.4,
162
+ "alpha": 0.32323481489443756,
163
+ "c": -0.15654010469243135,
164
+ "w_re": 0.5242342419912024,
165
+ "w_im": -0.022562349938175484,
166
+ "b_re": 0.21576524294883775,
167
+ "b_im": -0.6266023624179726
168
+ },
169
+ {
170
+ "stage": 16,
171
+ "phasor": 7,
172
+ "step": 0.4,
173
+ "alpha": 0.38246986737507255,
174
+ "c": -0.2147413276234277,
175
+ "w_re": -0.21617966794992996,
176
+ "w_im": -0.22459058106093363,
177
+ "b_re": -0.28516924787727604,
178
+ "b_im": -0.38220503519117693
179
+ },
180
+ {
181
+ "stage": 17,
182
+ "phasor": 35,
183
+ "step": 0.4,
184
+ "alpha": 0.4874469955022705,
185
+ "c": -0.25982562636618545,
186
+ "w_re": -0.06928813567325691,
187
+ "w_im": 0.08125049006772672,
188
+ "b_re": -0.5474920442041195,
189
+ "b_im": -0.4329639422253049
190
+ },
191
+ {
192
+ "stage": 18,
193
+ "phasor": 7,
194
+ "step": 0.4,
195
+ "alpha": 0.23313309453069142,
196
+ "c": -0.20600477096021788,
197
+ "w_re": -0.3009257213497396,
198
+ "w_im": 0.13069129184720474,
199
+ "b_re": -0.6326839574295885,
200
+ "b_im": 0.17694929657938274
201
+ },
202
+ {
203
+ "stage": 19,
204
+ "phasor": 37,
205
+ "step": 0.4,
206
+ "alpha": 0.28730901018115956,
207
+ "c": -0.15005369280276284,
208
+ "w_re": 0.03957746462383685,
209
+ "w_im": 0.3723175342063423,
210
+ "b_re": 0.6410602230773064,
211
+ "b_im": 0.28062304657742676
212
+ },
213
+ {
214
+ "stage": 20,
215
+ "phasor": 7,
216
+ "step": 0.4,
217
+ "alpha": 0.28519353933712877,
218
+ "c": -0.17451424341084584,
219
+ "w_re": -0.2908225000410197,
220
+ "w_im": -0.08622435303219662,
221
+ "b_re": -0.4734675098521598,
222
+ "b_im": -0.21330737439902567
223
+ },
224
+ {
225
+ "stage": 21,
226
+ "phasor": 37,
227
+ "step": 0.4,
228
+ "alpha": 0.2970907363736211,
229
+ "c": -0.1178773969954637,
230
+ "w_re": -0.3989069827974416,
231
+ "w_im": -0.07529477574269595,
232
+ "b_re": -0.35175675120207067,
233
+ "b_im": 0.49740270529990493
234
+ },
235
+ {
236
+ "stage": 22,
237
+ "phasor": 7,
238
+ "step": 0.4,
239
+ "alpha": 0.37066569007541217,
240
+ "c": -0.16185980179000573,
241
+ "w_re": -0.22809406940524715,
242
+ "w_im": -0.11886525281603842,
243
+ "b_re": -0.35322820761390417,
244
+ "b_im": -0.2570646508447083
245
+ },
246
+ {
247
+ "stage": 23,
248
+ "phasor": 23,
249
+ "step": 0.4,
250
+ "alpha": -0.5451648207088828,
251
+ "c": 0.282245459221117,
252
+ "w_re": 0.047159568889624894,
253
+ "w_im": -0.2040079448298226,
254
+ "b_re": 0.3965514578657553,
255
+ "b_im": 0.6069872253964642
256
+ },
257
+ {
258
+ "stage": 24,
259
+ "phasor": 7,
260
+ "step": 0.4,
261
+ "alpha": -0.25539641704723604,
262
+ "c": 0.02396375535599597,
263
+ "w_re": 0.32977133588371066,
264
+ "w_im": 0.08851392344856994,
265
+ "b_re": -0.4572718425152726,
266
+ "b_im": -0.2897221423013389
267
+ },
268
+ {
269
+ "stage": 25,
270
+ "phasor": 23,
271
+ "step": 0.025,
272
+ "alpha": 0.008226382235465172,
273
+ "c": 0.0013692050922035478,
274
+ "w_re": -0.03428460210809726,
275
+ "w_im": 0.010812468399711284,
276
+ "b_re": -0.06326699564312806,
277
+ "b_im": -0.009243185562406862
278
+ },
279
+ {
280
+ "stage": 26,
281
+ "phasor": 23,
282
+ "step": 0.4,
283
+ "alpha": -0.7420161660730024,
284
+ "c": 0.23764815749766757,
285
+ "w_re": 0.03982089300799257,
286
+ "w_im": 0.17240512070753275,
287
+ "b_re": -0.4892201750994508,
288
+ "b_im": -0.2952743889425063
289
+ },
290
+ {
291
+ "stage": 27,
292
+ "phasor": 7,
293
+ "step": 0.4,
294
+ "alpha": -0.14598679559107128,
295
+ "c": 0.031156280222117184,
296
+ "w_re": 0.36130904971699535,
297
+ "w_im": -0.010970602787876394,
298
+ "b_re": -0.726254710331812,
299
+ "b_im": -0.13817064498228462
300
+ },
301
+ {
302
+ "stage": 28,
303
+ "phasor": 20,
304
+ "step": 0.025,
305
+ "alpha": -0.02300750361080351,
306
+ "c": 0.004733909131491805,
307
+ "w_re": 0.0585347629743131,
308
+ "w_im": -0.015347370541884548,
309
+ "b_re": -0.044904152761356585,
310
+ "b_im": 0.23175420943107453
311
+ },
312
+ {
313
+ "stage": 29,
314
+ "phasor": 20,
315
+ "step": 0.025,
316
+ "alpha": -0.012219946073628111,
317
+ "c": 0.0034756243389614753,
318
+ "w_re": 0.05050906649153878,
319
+ "w_im": 0.0022392988832080317,
320
+ "b_re": 0.06080097497476472,
321
+ "b_im": 0.29849834249303925
322
+ },
323
+ {
324
+ "stage": 30,
325
+ "phasor": 20,
326
+ "step": 0.4,
327
+ "alpha": -0.07245884705741887,
328
+ "c": 0.007937468536585873,
329
+ "w_re": 0.08686901653288612,
330
+ "w_im": -0.11891548353411593,
331
+ "b_re": 0.18705320187968782,
332
+ "b_im": 0.26558986783495725
333
+ },
334
+ {
335
+ "stage": 31,
336
+ "phasor": 20,
337
+ "step": 0.025,
338
+ "alpha": -0.013620614511163988,
339
+ "c": 0.002195845127379097,
340
+ "w_re": 0.03885121657478746,
341
+ "w_im": 0.04417276467132002,
342
+ "b_re": -0.16787260736405163,
343
+ "b_im": 0.09081694318060904
344
+ }
345
+ ],
346
+ "inner_auc": null,
347
+ "labels": [
348
+ "a_sun",
349
+ "a_moon",
350
+ "a_mercury",
351
+ "a_venus",
352
+ "a_mars",
353
+ "a_jupiter",
354
+ "a_saturn",
355
+ "a_uranus",
356
+ "a_neptune",
357
+ "a_pluto",
358
+ "a_true_node",
359
+ "a_true_south_node",
360
+ "a_chiron",
361
+ "a_mean_lilith",
362
+ "m_sun",
363
+ "m_moon",
364
+ "m_mercury",
365
+ "m_venus",
366
+ "m_mars",
367
+ "m_jupiter",
368
+ "m_saturn",
369
+ "m_uranus",
370
+ "m_neptune",
371
+ "m_pluto",
372
+ "m_true_node",
373
+ "m_true_south_node",
374
+ "m_chiron",
375
+ "m_mean_lilith",
376
+ "d_sun",
377
+ "d_moon",
378
+ "d_mercury",
379
+ "d_venus",
380
+ "d_mars",
381
+ "d_jupiter",
382
+ "d_saturn",
383
+ "d_uranus",
384
+ "d_neptune",
385
+ "d_pluto",
386
+ "d_true_node",
387
+ "d_true_south_node",
388
+ "d_chiron",
389
+ "d_mean_lilith",
390
+ "mn_sun",
391
+ "mn_moon",
392
+ "mn_mercury",
393
+ "mn_venus",
394
+ "mn_mars",
395
+ "mn_jupiter",
396
+ "mn_saturn",
397
+ "mn_uranus",
398
+ "mn_neptune",
399
+ "mn_pluto",
400
+ "mn_true_node",
401
+ "mn_true_south_node",
402
+ "mn_chiron",
403
+ "mn_mean_lilith",
404
+ "dn_sun",
405
+ "dn_moon",
406
+ "dn_mercury",
407
+ "dn_venus",
408
+ "dn_mars",
409
+ "dn_jupiter",
410
+ "dn_saturn",
411
+ "dn_uranus",
412
+ "dn_neptune",
413
+ "dn_pluto",
414
+ "dn_true_node",
415
+ "dn_true_south_node",
416
+ "dn_chiron",
417
+ "dn_mean_lilith",
418
+ "tn_sun",
419
+ "tn_moon",
420
+ "tn_mercury",
421
+ "tn_venus",
422
+ "tn_mars",
423
+ "tn_jupiter",
424
+ "tn_saturn",
425
+ "tn_uranus",
426
+ "tn_neptune",
427
+ "tn_pluto",
428
+ "tn_true_node",
429
+ "tn_true_south_node",
430
+ "tn_chiron",
431
+ "tn_mean_lilith"
432
+ ],
433
+ "terms": [
434
+ "a",
435
+ "m",
436
+ "d",
437
+ "mn",
438
+ "dn",
439
+ "tn"
440
+ ],
441
+ "bodies": [
442
+ "sun",
443
+ "moon",
444
+ "mercury",
445
+ "venus",
446
+ "mars",
447
+ "jupiter",
448
+ "saturn",
449
+ "uranus",
450
+ "neptune",
451
+ "pluto",
452
+ "true_node",
453
+ "true_south_node",
454
+ "chiron",
455
+ "mean_lilith"
456
+ ],
457
+ "fitted_on": "train + test rows with both natal charts",
458
+ "n_rows": 16802,
459
+ "leaderboard_estimate": {
460
+ "inner_auc": 0.631962843164345,
461
+ "note": "the same construction fitted on train alone; its held-out AUC is what the leaderboard reports"
462
+ },
463
+ "phase_convention": {
464
+ "zodiac": "sidereal, Lahiri",
465
+ "engine": "Kerykeion 5.12.9 (Swiss Ephemeris)",
466
+ "birth_time": "09:00 local at the birthplace",
467
+ "wedding_time": "12:00 UT",
468
+ "presence_rule": "a term exists only when both of its phases exist; a missing phase contributes zero"
469
+ },
470
+ "explanation": {
471
+ "used": [
472
+ {
473
+ "phasor": "a_uranus",
474
+ "term": "a",
475
+ "body": "Uranus",
476
+ "meaning": "mom's longitude minus dad's \u2014 the synastry angle between the two natal charts, for Uranus",
477
+ "stages": 12,
478
+ "contribution": 0.2214107340167794,
479
+ "phase_deg": 192.5110089197106
480
+ },
481
+ {
482
+ "phasor": "d_pluto",
483
+ "term": "d",
484
+ "body": "Pluto",
485
+ "meaning": "the wedding-day longitude minus dad's natal longitude \u2014 the wedding sky transiting dad, for Pluto",
486
+ "stages": 5,
487
+ "contribution": 0.17033211706971024,
488
+ "phase_deg": 294.57847845844117
489
+ },
490
+ {
491
+ "phasor": "d_neptune",
492
+ "term": "d",
493
+ "body": "Neptune",
494
+ "meaning": "the wedding-day longitude minus dad's natal longitude \u2014 the wedding sky transiting dad, for Neptune",
495
+ "stages": 3,
496
+ "contribution": 0.09592685587208412,
497
+ "phase_deg": 199.78290531086967
498
+ },
499
+ {
500
+ "phasor": "d_uranus",
501
+ "term": "d",
502
+ "body": "Uranus",
503
+ "meaning": "the wedding-day longitude minus dad's natal longitude \u2014 the wedding sky transiting dad, for Uranus",
504
+ "stages": 4,
505
+ "contribution": 0.05587273722088104,
506
+ "phase_deg": 87.88080007836476
507
+ },
508
+ {
509
+ "phasor": "m_pluto",
510
+ "term": "m",
511
+ "body": "Pluto",
512
+ "meaning": "the wedding-day longitude minus mom's natal longitude \u2014 the wedding sky transiting mom, for Pluto",
513
+ "stages": 3,
514
+ "contribution": 0.018853770064656265,
515
+ "phase_deg": 134.11924787097027
516
+ },
517
+ {
518
+ "phasor": "m_saturn",
519
+ "term": "m",
520
+ "body": "Saturn",
521
+ "meaning": "the wedding-day longitude minus mom's natal longitude \u2014 the wedding sky transiting mom, for Saturn",
522
+ "stages": 4,
523
+ "contribution": 0.0006326350047239863,
524
+ "phase_deg": 102.91973323230647
525
+ }
526
+ ],
527
+ "unused": [
528
+ "a_sun",
529
+ "a_moon",
530
+ "a_mercury",
531
+ "a_venus",
532
+ "a_mars",
533
+ "a_jupiter",
534
+ "a_saturn",
535
+ "a_neptune",
536
+ "a_pluto",
537
+ "a_true_node",
538
+ "a_true_south_node",
539
+ "a_chiron",
540
+ "a_mean_lilith",
541
+ "m_sun",
542
+ "m_moon",
543
+ "m_mercury",
544
+ "m_venus",
545
+ "m_mars",
546
+ "m_jupiter",
547
+ "m_uranus",
548
+ "m_neptune",
549
+ "m_true_node",
550
+ "m_true_south_node",
551
+ "m_chiron",
552
+ "m_mean_lilith",
553
+ "d_sun",
554
+ "d_moon",
555
+ "d_mercury",
556
+ "d_venus",
557
+ "d_mars",
558
+ "d_jupiter",
559
+ "d_saturn",
560
+ "d_true_node",
561
+ "d_true_south_node",
562
+ "d_chiron",
563
+ "d_mean_lilith",
564
+ "mn_sun",
565
+ "mn_moon",
566
+ "mn_mercury",
567
+ "mn_venus",
568
+ "mn_mars",
569
+ "mn_jupiter",
570
+ "mn_saturn",
571
+ "mn_uranus",
572
+ "mn_neptune",
573
+ "mn_pluto",
574
+ "mn_true_node",
575
+ "mn_true_south_node",
576
+ "mn_chiron",
577
+ "mn_mean_lilith",
578
+ "dn_sun",
579
+ "dn_moon",
580
+ "dn_mercury",
581
+ "dn_venus",
582
+ "dn_mars",
583
+ "dn_jupiter",
584
+ "dn_saturn",
585
+ "dn_uranus",
586
+ "dn_neptune",
587
+ "dn_pluto",
588
+ "dn_true_node",
589
+ "dn_true_south_node",
590
+ "dn_chiron",
591
+ "dn_mean_lilith",
592
+ "tn_sun",
593
+ "tn_moon",
594
+ "tn_mercury",
595
+ "tn_venus",
596
+ "tn_mars",
597
+ "tn_jupiter",
598
+ "tn_saturn",
599
+ "tn_uranus",
600
+ "tn_neptune",
601
+ "tn_pluto",
602
+ "tn_true_node",
603
+ "tn_true_south_node",
604
+ "tn_chiron",
605
+ "tn_mean_lilith"
606
+ ]
607
+ }
608
+ }
artamodel_ensemble.json ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "3-term single ArtaModel F=1": {
3
+ "held": 0.621164730673447,
4
+ "age_cell_matched": 0.4944578313253012,
5
+ "inner": 0.6442
6
+ },
7
+ "3-term BAG 25 bootstraps (rank-average)": {
8
+ "held": 0.6307294659909557,
9
+ "age_cell_matched": 0.4977037562012757,
10
+ "inner_mean": 0.6313
11
+ },
12
+ "3-term BOOST single-sum fields nu=0.05": {
13
+ "held": 0.631740420171482,
14
+ "age_cell_matched": 0.5033309709425939,
15
+ "inner": 0.6519,
16
+ "stages": 2
17
+ },
18
+ "3-term BOOST single-sum fields nu=0.1": {
19
+ "held": 0.6317839582498853,
20
+ "age_cell_matched": 0.5035294117647059,
21
+ "inner": 0.6521,
22
+ "stages": 2
23
+ },
24
+ "3-term BOOST single-sum fields nu=0.3": {
25
+ "held": 0.6318925131920378,
26
+ "age_cell_matched": 0.5050885896527285,
27
+ "inner": 0.6509,
28
+ "stages": 2
29
+ },
30
+ "3-term SPLIT per body (14 single sums), linear head": {
31
+ "held": 0.6351961825812856,
32
+ "age_cell_matched": 0.5039829907866761,
33
+ "inner": 0.6486
34
+ },
35
+ "3-term SPLIT per body -> LightGBM on the 14 intensities": {
36
+ "held": 0.6255905214700778,
37
+ "age_cell_matched": 0.4813607370659107
38
+ },
39
+ "3-term SPLIT per body -> LightGBM on intensities + plain columns": {
40
+ "held": 0.6212007221515938,
41
+ "age_cell_matched": 0.4757193479801559
42
+ },
43
+ "3-term BOOST over SPLIT per body (stage k -> group k)": {
44
+ "held": 0.6329722575364414,
45
+ "age_cell_matched": 0.4968674698795181,
46
+ "inner": 0.6474,
47
+ "stages": 8
48
+ },
49
+ "3-term SPLIT per term (3 single sums), linear head": {
50
+ "held": 0.6183614589319819,
51
+ "age_cell_matched": 0.4951381998582566,
52
+ "inner": 0.6484
53
+ },
54
+ "3-term SPLIT per term -> LightGBM on the 3 intensities": {
55
+ "held": 0.6181725036717113,
56
+ "age_cell_matched": 0.5008929836995039
57
+ },
58
+ "3-term SPLIT per term -> LightGBM on intensities + plain columns": {
59
+ "held": 0.6203479563225998,
60
+ "age_cell_matched": 0.48731396172927
61
+ },
62
+ "3-term BOOST over SPLIT per term (stage k -> group k)": {
63
+ "held": 0.6335063246315227,
64
+ "age_cell_matched": 0.49992912827781716,
65
+ "inner": 0.6463,
66
+ "stages": 5
67
+ },
68
+ "3-term SPLIT per phasor (42 single sums), linear head": {
69
+ "held": 0.6407121668611367,
70
+ "age_cell_matched": 0.5141318214032601,
71
+ "inner": 0.6503
72
+ },
73
+ "3-term SPLIT per phasor -> LightGBM on the 42 intensities": {
74
+ "held": 0.6297069016561885,
75
+ "age_cell_matched": 0.506392629340893
76
+ },
77
+ "3-term SPLIT per phasor -> LightGBM on intensities + plain columns": {
78
+ "held": 0.626497274516292,
79
+ "age_cell_matched": 0.4992487597448618
80
+ },
81
+ "3-term BOOST over SPLIT per phasor (stage k -> group k)": {
82
+ "held": 0.6388313218741111,
83
+ "age_cell_matched": 0.5225797306874557,
84
+ "inner": 0.6385,
85
+ "stages": 36
86
+ },
87
+ "6-term single ArtaModel F=1": {
88
+ "held": 0.5807439786837568,
89
+ "age_cell_matched": 0.5120056697377746,
90
+ "inner": 0.5655
91
+ },
92
+ "6-term BAG 25 bootstraps (rank-average)": {
93
+ "held": 0.6020613828854716,
94
+ "age_cell_matched": 0.4871438695960312,
95
+ "inner_mean": 0.5876
96
+ },
97
+ "6-term BOOST single-sum fields nu=0.05": {
98
+ "held": 0.5964200089398187,
99
+ "age_cell_matched": 0.4888164422395464,
100
+ "inner": 0.6332,
101
+ "stages": 47
102
+ },
103
+ "6-term BOOST single-sum fields nu=0.1": {
104
+ "held": 0.5991878697108491,
105
+ "age_cell_matched": 0.5050885896527285,
106
+ "inner": 0.6426,
107
+ "stages": 41
108
+ },
109
+ "6-term BOOST single-sum fields nu=0.3": {
110
+ "held": 0.5904163981818499,
111
+ "age_cell_matched": 0.4670446491849752,
112
+ "inner": 0.6599,
113
+ "stages": 47
114
+ },
115
+ "6-term SPLIT per body (14 single sums), linear head": {
116
+ "held": 0.6146363409437894,
117
+ "age_cell_matched": 0.49250177179305454,
118
+ "inner": 0.6461
119
+ },
120
+ "6-term SPLIT per body -> LightGBM on the 14 intensities": {
121
+ "held": 0.6027858565101037,
122
+ "age_cell_matched": 0.4847909284195606
123
+ },
124
+ "6-term SPLIT per body -> LightGBM on intensities + plain columns": {
125
+ "held": 0.6176416293690462,
126
+ "age_cell_matched": 0.48022678951098513
127
+ },
128
+ "6-term BOOST over SPLIT per body (stage k -> group k)": {
129
+ "held": 0.6300691384685045,
130
+ "age_cell_matched": 0.4945428773919206,
131
+ "inner": 0.6547,
132
+ "stages": 21
133
+ },
134
+ "6-term SPLIT per term (6 single sums), linear head": {
135
+ "held": 0.617382142421762,
136
+ "age_cell_matched": 0.4898086463501063,
137
+ "inner": 0.6499
138
+ },
139
+ "6-term SPLIT per term -> LightGBM on the 6 intensities": {
140
+ "held": 0.6170651852109855,
141
+ "age_cell_matched": 0.4919631467044649
142
+ },
143
+ "6-term SPLIT per term -> LightGBM on intensities + plain columns": {
144
+ "held": 0.6190958011877188,
145
+ "age_cell_matched": 0.4944578313253012
146
+ },
147
+ "6-term BOOST over SPLIT per term (stage k -> group k)": {
148
+ "held": 0.6311959039375838,
149
+ "age_cell_matched": 0.49593196314670446,
150
+ "inner": 0.6484,
151
+ "stages": 24
152
+ },
153
+ "6-term SPLIT per phasor (84 single sums), linear head": {
154
+ "held": 0.6108618798000731,
155
+ "age_cell_matched": 0.4908008504606662,
156
+ "inner": 0.635
157
+ },
158
+ "6-term SPLIT per phasor -> LightGBM on the 84 intensities": {
159
+ "held": 0.6084115567475314,
160
+ "age_cell_matched": 0.48289156626506025
161
+ },
162
+ "6-term SPLIT per phasor -> LightGBM on intensities + plain columns": {
163
+ "held": 0.6225875550756692,
164
+ "age_cell_matched": 0.4939759036144578
165
+ },
166
+ "6-term BOOST over SPLIT per phasor (stage k -> group k)": {
167
+ "held": 0.6400434219768609,
168
+ "age_cell_matched": 0.5133380581148121,
169
+ "inner": 0.6359,
170
+ "stages": 40
171
+ },
172
+ "REFERENCE plain columns (two ages, gap, start year), LightGBM": {
173
+ "held": 0.6331707911739608,
174
+ "age_cell_matched": 0.5098086463501063
175
+ }
176
+ }
artamodel_ensemble.py ADDED
@@ -0,0 +1,270 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ artamodel_ensemble.py — ArtaModel as ensembles, as boosting, and as SPLIT single-sum models.
3
+
4
+ Arash, 2026-08-18: "use ensembles and boosting techniques and split multiple single sum model".
5
+
6
+ BAG many ArtaModels (F=1, the literal formula), each on a bootstrap of the rows with its own seed; the
7
+ held-out scores rank-averaged. Variance reduction for the one-field fit.
8
+ BOOST functional gradient boosting whose weak learner is ONE single-sum field |b + Σ_j w_j z_j|²: at each
9
+ stage the field is fitted by least squares to the current pseudo-residuals of the logistic loss
10
+ (r = y − p), scaled by a line-searched coefficient, shrunk by ν, and added; early-stopped on the inner
11
+ temporal split. Each stage is a complete ArtaModel-shaped sum; the model is a sum of K such squares.
12
+ SPLIT the phasors partitioned into groups G_k -- per BODY (each body's own terms), per TERM TYPE (all bodies
13
+ of one term), or per body×term (single phasors) -- and every group given its OWN single-sum field whose
14
+ weights are masked to the group. The K intensities are combined (i) by a linear head (the coherent
15
+ model with a block-diagonal weight matrix), and (ii) by LightGBM on the K intensities -- boosting over
16
+ the split single-sum models -- with the plain columns optionally beside them.
17
+
18
+ Everything on the FULL population unless stated (both natal charts + the wedding day known: 6,258 train / 2,635
19
+ held out), the same protocol as the study: choices on the inner temporal split, held out read for the record,
20
+ the age-cell-matched control beside every headline.
21
+
22
+ Usage: AQ_PHASES=/tmp/aq3feat/phases.npz AQ_SOL=/tmp/aq3comp/solution.csv AQ_OUT=/tmp/aq3feat python artamodel_ensemble.py
23
+ """
24
+ import json
25
+ import os
26
+ import sys
27
+ import time
28
+
29
+ import numpy as np
30
+ import pandas as pd
31
+ from scipy.stats import rankdata
32
+
33
+ HERE = os.path.dirname(os.path.abspath(__file__))
34
+ sys.path.insert(0, HERE)
35
+ sys.path.insert(0, os.path.join(HERE, "..", "coherent"))
36
+ from artamodel import ANGLES, BODIES14, TERMS, ArtaModel, auc, phase_matrix # noqa: E402
37
+ from coherent_fit import Coherent # noqa: E402
38
+
39
+ PH = os.environ.get("AQ_PHASES", "/tmp/aq3feat/phases.npz")
40
+ SOL = os.environ.get("AQ_SOL", "/tmp/aq3comp/solution.csv")
41
+ OUT = os.environ.get("AQ_OUT", "/tmp/aq3feat")
42
+ T0 = time.time()
43
+ R = {}
44
+
45
+
46
+ def log(*a):
47
+ print(f"[{time.time()-T0:6.0f}s]", *a, flush=True)
48
+
49
+
50
+ def r01(v):
51
+ r = rankdata(v); return (r - 1) / max(1.0, len(r) - 1)
52
+
53
+
54
+ def cs(P):
55
+ rad = np.pi / 180.0
56
+ return np.nan_to_num(np.cos(P * rad)), np.nan_to_num(np.sin(P * rad))
57
+
58
+
59
+ def matched(y, s, cell):
60
+ num = den = 0.0
61
+ for b in np.unique(cell):
62
+ m = cell == b; n1, n0 = int(y[m].sum()), int((1 - y[m]).sum())
63
+ if n1 and n0:
64
+ num += auc(y[m], s[m]) * n1 * n0; den += n1 * n0
65
+ return num / den if den else float("nan")
66
+
67
+
68
+ # ── one single-sum field fitted by LEAST SQUARES to a residual (the boosting weak learner) ─────────────────────
69
+ class Field:
70
+ """u(x) = |b + Σ_j w_j z_j|², w and b complex, fitted so that α·u + c ≈ r by Adam on the squared error."""
71
+
72
+ def __init__(self, K, seed, mask=None):
73
+ g = np.random.default_rng(seed)
74
+ sc = 1.0 / np.sqrt(K)
75
+ self.A1 = g.normal(0, sc, K); self.A2 = g.normal(0, sc, K)
76
+ self.br, self.bi = g.normal(0, 0.3), g.normal(0, 0.3)
77
+ self.alpha, self.c = 0.0, 0.0
78
+ self.mask = np.ones(K) if mask is None else mask.astype(float)
79
+ self.A1 *= self.mask; self.A2 *= self.mask
80
+ self.m = {k: 0.0 for k in ("A1", "A2", "br", "bi", "alpha", "c")}; self.v = dict(self.m); self.t = 0
81
+
82
+ def u(self, C, S):
83
+ Zr = C @ self.A1 + S @ self.A2 + self.br
84
+ Zi = S @ self.A1 - C @ self.A2 + self.bi
85
+ return Zr * Zr + Zi * Zi, Zr, Zi
86
+
87
+ def fit(self, C, S, r, C_in, S_in, r_in, lr=0.02, steps=300, patience=40, l2=1e-3):
88
+ best, state, bad = np.inf, None, 0
89
+ for t in range(steps):
90
+ u, Zr, Zi = self.u(C, S)
91
+ pred = self.alpha * u + self.c
92
+ e = pred - r # d(0.5*mse)/dpred, per row / n
93
+ n = len(r)
94
+ g_alpha = float((e * u).mean()); g_c = float(e.mean())
95
+ gu = e * self.alpha / n # dL/du
96
+ gr, gi = 2.0 * gu * Zr, 2.0 * gu * Zi
97
+ gA1 = (gr @ C + gi @ S) * self.mask + l2 * self.A1
98
+ gA2 = (gr @ S - gi @ C) * self.mask + l2 * self.A2
99
+ gbr, gbi = float(gr.sum()), float(gi.sum())
100
+ self.t += 1
101
+ for k, gval in (("A1", gA1), ("A2", gA2), ("br", gbr), ("bi", gbi), ("alpha", g_alpha), ("c", g_c)):
102
+ self.m[k] = 0.9 * self.m[k] + 0.1 * gval; self.v[k] = 0.999 * self.v[k] + 0.001 * (gval * gval)
103
+ mh = self.m[k] / (1 - 0.9 ** self.t); vh = self.v[k] / (1 - 0.999 ** self.t)
104
+ setattr(self, k, getattr(self, k) - lr * mh / (np.sqrt(vh) + 1e-8))
105
+ if t % 5 == 0:
106
+ ui, _, _ = self.u(C_in, S_in)
107
+ loss_in = float(np.mean((self.alpha * ui + self.c - r_in) ** 2))
108
+ if loss_in < best - 1e-7:
109
+ best, bad = loss_in, 0
110
+ state = (self.A1.copy(), self.A2.copy(), self.br, self.bi, self.alpha, self.c)
111
+ else:
112
+ bad += 5
113
+ if bad >= patience:
114
+ break
115
+ if state is not None:
116
+ self.A1, self.A2, self.br, self.bi, self.alpha, self.c = state
117
+ return self
118
+
119
+ def predict(self, C, S):
120
+ u, _, _ = self.u(C, S)
121
+ return self.alpha * u + self.c
122
+
123
+
124
+ def boost(P, y, inner, Pte, stages=60, nu=0.1, seed=0, masks=None):
125
+ """Gradient boosting with single-sum fields. `masks` (optional list) restricts stage k's field to a group,
126
+ cycling through the groups -- the SPLIT models boosted in turn."""
127
+ C, S = cs(P); Cte, Ste = cs(Pte)
128
+ fit = ~inner
129
+ F = np.zeros(len(y)); Fte = np.zeros(len(Pte))
130
+ p0 = np.clip(y[fit].mean(), 1e-3, 1 - 1e-3); F[:] = np.log(p0 / (1 - p0)); Fte[:] = F[0]
131
+ best, best_stage, bad, best_Fte = -1.0, 0, 0, Fte.copy()
132
+ for k in range(stages):
133
+ p = 1 / (1 + np.exp(-F)); r = y - p
134
+ mask = None if masks is None else masks[k % len(masks)]
135
+ f = Field(C.shape[1], seed * 1000 + k, mask).fit(C[fit], S[fit], r[fit], C[inner], S[inner], r[inner])
136
+ h = f.predict(C, S); hte = f.predict(Cte, Ste)
137
+ # line search of the step on the logistic loss over the fit rows
138
+ best_g, best_loss = 0.0, np.inf
139
+ for gam in (0.25, 0.5, 1.0, 2.0, 4.0):
140
+ Fx = F[fit] + nu * gam * h[fit]
141
+ loss = float(np.mean(np.logaddexp(0, -Fx * (2 * y[fit] - 1))))
142
+ if loss < best_loss:
143
+ best_loss, best_g = loss, gam
144
+ F = F + nu * best_g * h; Fte = Fte + nu * best_g * hte
145
+ a = auc(y[inner], F[inner])
146
+ if a > best + 1e-5:
147
+ best, best_stage, bad, best_Fte = a, k + 1, 0, Fte.copy()
148
+ else:
149
+ bad += 1
150
+ if bad >= 10:
151
+ break
152
+ return best, best_stage, best_Fte
153
+
154
+
155
+ def split_heads(P, y, inner, Pte, groups, F_seed=0):
156
+ """SPLIT single-sum models with a LINEAR head: one field per group, weights masked to the group, K intensities
157
+ combined by the coherent model's own head. Returns (inner auc, held-out score, K intensities train/test)."""
158
+ C, S = cs(P); Cte, Ste = cs(Pte)
159
+ K = C.shape[1]; G = len(groups)
160
+ m = Coherent(K, G, seed=F_seed)
161
+ M = np.zeros((G, K))
162
+ for gi, cols in enumerate(groups):
163
+ M[gi, cols] = 1.0
164
+ m.A1 *= M; m.A2 *= M
165
+ rng = np.random.default_rng(7 + F_seed); idx = np.where(~inner)[0]
166
+ batch = int(min(1024, max(32, len(idx) // 16)))
167
+ best, state, bad = -1.0, None, 0
168
+ for ep in range(150):
169
+ rng.shuffle(idx)
170
+ for s0 in range(0, len(idx), batch):
171
+ b = idx[s0:s0 + batch]
172
+ if len(b) >= 32:
173
+ m.step(C[b], S[b], y[b].astype(float), 0.01, 1e-3)
174
+ m.A1 *= M; m.A2 *= M # keep every field inside its group
175
+ a = auc(y[inner], m.logit(C[inner], S[inner])[0])
176
+ if a > best + 1e-5:
177
+ best, bad = a, 0
178
+ state = (m.A1.copy(), m.A2.copy(), m.br.copy(), m.bi.copy(), m.w.copy(), m.c, m.mu.copy(), m.sd.copy())
179
+ else:
180
+ bad += 1
181
+ if bad >= 15:
182
+ break
183
+ m.A1, m.A2, m.br, m.bi, m.w, m.c, m.mu, m.sd = state
184
+ return best, m.logit(Cte, Ste)[0], m.fields(C, S)[2], m.fields(Cte, Ste)[2]
185
+
186
+
187
+ def main():
188
+ import lightgbm as lgb
189
+ Z = np.load(PH, allow_pickle=True)
190
+ bodies = list(Z["bodies"]); ids = Z["id_test"]; ytr = Z["y_train"].astype(np.int64)
191
+ Dtr, Mtr, Wtr = Z["theta_dad_train"], Z["theta_mom_train"], Z["theta_wed_train"]
192
+ Dte, Mte, Wte = Z["theta_dad_test"], Z["theta_mom_test"], Z["theta_wed_test"]
193
+ ptr, pte, pn = Z["plain_train"], Z["plain_test"], list(Z["plain_names"])
194
+ later = Z["yr_train"].astype(int).max(1)
195
+ sol = pd.read_csv(SOL).set_index("id"); lab = [c for c in sol.columns if c != "Usage"][0]
196
+ yte = sol.loc[ids, lab].to_numpy().astype(int)
197
+ j1 = ptr[:, pn.index("start_is_jan1")] == 1.0; j1e = pte[:, pn.index("start_is_jan1")] == 1.0
198
+ Wtr = Wtr.copy(); Wte = Wte.copy(); Wtr[j1] = np.nan; Wte[j1e] = np.nan
199
+ B = [bodies.index(b) for b in BODIES14]
200
+ full_tr = np.isfinite(Dtr[:, B]).all(1) & np.isfinite(Mtr[:, B]).all(1) & np.isfinite(Wtr[:, B]).all(1)
201
+ full_te = np.isfinite(Dte[:, B]).all(1) & np.isfinite(Mte[:, B]).all(1) & np.isfinite(Wte[:, B]).all(1)
202
+ ages_te = pte[full_te][:, [pn.index("age_dad_at_start"), pn.index("age_mom_at_start")]]
203
+ cell = (np.floor(ages_te[:, 0] / 3) * 1000 + np.floor(ages_te[:, 1] / 3)).astype(int)
204
+ y = ytr[full_tr]; ye = yte[full_te]; lat = later[full_tr]; inner = lat > np.quantile(lat, 0.85)
205
+ log(f"FULL population: train {len(y):,} (inner {int(inner.sum()):,}) · held out {len(ye):,}")
206
+ cols = [pn.index(c) for c in ("age_dad_at_start", "age_mom_at_start", "age_gap", "start_year")]
207
+ Xp_tr, Xp_te = ptr[full_tr][:, cols], pte[full_te][:, cols]
208
+
209
+ def report(name, s, extra=None):
210
+ a = auc(ye, s); m_ = matched(ye, s, cell)
211
+ R[name] = {"held": a, "age_cell_matched": m_, **(extra or {})}
212
+ log(f" {name:<58} held {a:.4f} age-cell-matched {m_:.4f}" + (f" {extra}" if extra else ""))
213
+
214
+ for terms, tag in ((("a", "m", "d"), "3-term"), (TERMS, "6-term")):
215
+ P, labels = phase_matrix(Dtr, Mtr, Wtr, bodies, BODIES14, terms); Pe, _ = phase_matrix(Dte, Mte, Wte, bodies, BODIES14, terms)
216
+ P, Pe = P[full_tr], Pe[full_te]
217
+ log(f"── {tag}: {len(labels)} phasors ──")
218
+ # single ArtaModel, F=1 (the reference point)
219
+ am = ArtaModel(terms=terms, bodies=BODIES14, F=1).fit(P, y, inner)
220
+ report(f"{tag} single ArtaModel F=1", am.score(Pe), {"inner": round(am.inner_auc, 4)})
221
+ # BAG: 25 bootstraps x seeds, rank-averaged
222
+ rng = np.random.default_rng(0); scores = []; ivs = []
223
+ fit_idx = np.where(~inner)[0]
224
+ for b in range(25):
225
+ boot = rng.choice(fit_idx, size=len(fit_idx), replace=True)
226
+ keep = np.concatenate([boot, np.where(inner)[0]])
227
+ inn = np.zeros(len(keep), bool); inn[len(boot):] = True
228
+ a_ = ArtaModel(terms=terms, bodies=BODIES14, F=1, seed=b).fit(P[keep], y[keep], inn)
229
+ scores.append(r01(a_.score(Pe))); ivs.append(a_.inner_auc)
230
+ report(f"{tag} BAG 25 bootstraps (rank-average)", np.mean(scores, 0), {"inner_mean": round(float(np.mean(ivs)), 4)})
231
+ # BOOST: single-sum fields on residuals
232
+ for nu in (0.05, 0.1, 0.3):
233
+ iv, K, s = boost(P, y, inner, Pe, stages=60, nu=nu)
234
+ report(f"{tag} BOOST single-sum fields nu={nu}", s, {"inner": round(iv, 4), "stages": K})
235
+ # SPLIT: groups per body / per term / per phasor
236
+ by_body = [[k for k, l in enumerate(labels) if l.split("_", 1)[1] == b] for b in BODIES14]
237
+ by_term = [[k for k, l in enumerate(labels) if l.split("_", 1)[0] == t] for t in terms]
238
+ by_phasor = [[k] for k in range(len(labels))]
239
+ for gname, groups in (("per body", by_body), ("per term", by_term), ("per phasor", by_phasor)):
240
+ groups = [g for g in groups if g]
241
+ iv, s, Utr, Ute = split_heads(P, y, inner, Pe, groups)
242
+ report(f"{tag} SPLIT {gname} ({len(groups)} single sums), linear head", s, {"inner": round(iv, 4)})
243
+ # LightGBM on the K intensities (boosting over the split models), plus with the plain columns
244
+ def gbm(Xtr, Xte, seeds=3):
245
+ p = np.zeros(len(Xte))
246
+ for sd in range(seeds):
247
+ c = lgb.LGBMClassifier(n_estimators=300, learning_rate=0.03, num_leaves=7, min_child_samples=50, colsample_bytree=0.8,
248
+ subsample=0.8, subsample_freq=1, reg_lambda=10.0, random_state=sd, verbose=-1)
249
+ c.fit(Xtr[~inner], y[~inner]); p += c.predict_proba(Xte)[:, 1]
250
+ return p / seeds
251
+ report(f"{tag} SPLIT {gname} -> LightGBM on the {len(groups)} intensities", gbm(Utr, Ute))
252
+ report(f"{tag} SPLIT {gname} -> LightGBM on intensities + plain columns", gbm(np.column_stack([Utr, Xp_tr]), np.column_stack([Ute, Xp_te])))
253
+ # boosting the split models in turn (each stage restricted to one group)
254
+ masks = [np.isin(np.arange(len(labels)), g) for g in groups]
255
+ iv, K, s = boost(P, y, inner, Pe, stages=min(120, 4 * len(groups)), nu=0.1, masks=masks)
256
+ report(f"{tag} BOOST over SPLIT {gname} (stage k -> group k)", s, {"inner": round(iv, 4), "stages": K})
257
+ # references on the same rows
258
+ def gbm_plain(seeds=3):
259
+ p = np.zeros(len(Xp_te))
260
+ for sd in range(seeds):
261
+ c = lgb.LGBMClassifier(n_estimators=300, learning_rate=0.03, num_leaves=7, min_child_samples=50, reg_lambda=10.0, random_state=sd, verbose=-1)
262
+ c.fit(Xp_tr[~inner], y[~inner]); p += c.predict_proba(Xp_te)[:, 1]
263
+ return p / seeds
264
+ report("REFERENCE plain columns (two ages, gap, start year), LightGBM", gbm_plain())
265
+ json.dump(R, open(os.path.join(OUT, "artamodel_ensemble.json"), "w"), indent=1)
266
+ log(f"wrote {OUT}/artamodel_ensemble.json")
267
+
268
+
269
+ if __name__ == "__main__":
270
+ main()
artamodel_leaderboard.json ADDED
@@ -0,0 +1,435 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "F0": -0.2510023573758644,
3
+ "stages": [
4
+ {
5
+ "stage": 1,
6
+ "phasor": 35,
7
+ "step": 0.4,
8
+ "alpha": 0.2790609079021399,
9
+ "c": -0.3139164088718358,
10
+ "w_re": 0.16544682051025963,
11
+ "w_im": -0.2717536379569562,
12
+ "b_re": 0.8537051175935859,
13
+ "b_im": 0.39014440491816765
14
+ },
15
+ {
16
+ "stage": 2,
17
+ "phasor": 35,
18
+ "step": 0.4,
19
+ "alpha": -0.4383853720692169,
20
+ "c": 0.1173512689580232,
21
+ "w_re": 0.23249329816544284,
22
+ "w_im": -0.4510036636235923,
23
+ "b_re": -0.4312355863893404,
24
+ "b_im": -0.4544389464444736
25
+ },
26
+ {
27
+ "stage": 3,
28
+ "phasor": 35,
29
+ "step": 0.4,
30
+ "alpha": 0.34994675789562907,
31
+ "c": -0.25146824654007244,
32
+ "w_re": 0.18318691499995837,
33
+ "w_im": -0.21215918714332932,
34
+ "b_re": 0.6088484312579715,
35
+ "b_im": 0.4163709198609787
36
+ },
37
+ {
38
+ "stage": 4,
39
+ "phasor": 37,
40
+ "step": 0.4,
41
+ "alpha": -0.5820355837861616,
42
+ "c": 0.22526205078391126,
43
+ "w_re": 0.5689653288215984,
44
+ "w_im": 0.257394190037747,
45
+ "b_re": -0.6536070580939775,
46
+ "b_im": -0.07819744063532238
47
+ },
48
+ {
49
+ "stage": 5,
50
+ "phasor": 7,
51
+ "step": 0.4,
52
+ "alpha": 0.30902073901399696,
53
+ "c": -0.36656379703454534,
54
+ "w_re": 0.45093515109026966,
55
+ "w_im": 0.31784612820100994,
56
+ "b_re": 0.45797385423834874,
57
+ "b_im": 0.41008636025743916
58
+ },
59
+ {
60
+ "stage": 6,
61
+ "phasor": 35,
62
+ "step": 0.4,
63
+ "alpha": -0.2888845705269212,
64
+ "c": 0.08357975402566378,
65
+ "w_re": -0.4427115061786578,
66
+ "w_im": -0.32150984551227346,
67
+ "b_re": -0.5324306908841805,
68
+ "b_im": 0.37366357041275694
69
+ },
70
+ {
71
+ "stage": 7,
72
+ "phasor": 7,
73
+ "step": 0.4,
74
+ "alpha": -0.3380538185780996,
75
+ "c": 0.07942775223701656,
76
+ "w_re": 0.16686411043437596,
77
+ "w_im": -0.3506490103077557,
78
+ "b_re": -0.3456586276390284,
79
+ "b_im": 0.6182341239570788
80
+ },
81
+ {
82
+ "stage": 8,
83
+ "phasor": 37,
84
+ "step": 0.4,
85
+ "alpha": 0.7608792714926302,
86
+ "c": -0.31757565826834183,
87
+ "w_re": 0.046712674010060504,
88
+ "w_im": 0.3424069935800981,
89
+ "b_re": 0.5909948084345692,
90
+ "b_im": 0.19999181642194766
91
+ },
92
+ {
93
+ "stage": 9,
94
+ "phasor": 7,
95
+ "step": 0.4,
96
+ "alpha": 0.2069111011215517,
97
+ "c": -0.20882936021322604,
98
+ "w_re": 0.21655920000025589,
99
+ "w_im": 0.43361399974882253,
100
+ "b_re": 0.33203701914438416,
101
+ "b_im": 0.52191876376675
102
+ },
103
+ {
104
+ "stage": 10,
105
+ "phasor": 35,
106
+ "step": 0.4,
107
+ "alpha": -0.21006169984120804,
108
+ "c": 0.05482326858295784,
109
+ "w_re": -0.1737283486567377,
110
+ "w_im": -0.34326716773408406,
111
+ "b_re": -0.6237033604178058,
112
+ "b_im": 0.10437845863523815
113
+ },
114
+ {
115
+ "stage": 11,
116
+ "phasor": 7,
117
+ "step": 0.4,
118
+ "alpha": -0.23596001023735938,
119
+ "c": 0.04793709815026776,
120
+ "w_re": 0.3042775595950708,
121
+ "w_im": 0.30411728121014864,
122
+ "b_re": -0.478025576284314,
123
+ "b_im": -0.4066299078084681
124
+ },
125
+ {
126
+ "stage": 12,
127
+ "phasor": 37,
128
+ "step": 0.4,
129
+ "alpha": -0.49022519323754793,
130
+ "c": 0.144709174758613,
131
+ "w_re": 0.4483943171312746,
132
+ "w_im": 0.1747243976256867,
133
+ "b_re": -0.5769310079468398,
134
+ "b_im": 0.0007063262364112111
135
+ },
136
+ {
137
+ "stage": 13,
138
+ "phasor": 7,
139
+ "step": 0.4,
140
+ "alpha": 0.325753320229801,
141
+ "c": -0.2413198050073399,
142
+ "w_re": -0.121212586683471,
143
+ "w_im": 0.3900741494361323,
144
+ "b_re": -0.23218812289357832,
145
+ "b_im": 0.44633056636642404
146
+ },
147
+ {
148
+ "stage": 14,
149
+ "phasor": 35,
150
+ "step": 0.4,
151
+ "alpha": 0.13939712628885229,
152
+ "c": -0.04283992755583811,
153
+ "w_re": 0.1941305187432573,
154
+ "w_im": 0.19793528765528204,
155
+ "b_re": -0.39677061320341045,
156
+ "b_im": 0.2534717678746568
157
+ },
158
+ {
159
+ "stage": 15,
160
+ "phasor": 35,
161
+ "step": 0.4,
162
+ "alpha": -0.27729186832613145,
163
+ "c": 0.037781545123303596,
164
+ "w_re": 0.3742135911064377,
165
+ "w_im": 0.19413612657427187,
166
+ "b_re": 0.36760760197091547,
167
+ "b_im": -0.3070663472768141
168
+ },
169
+ {
170
+ "stage": 16,
171
+ "phasor": 7,
172
+ "step": 0.4,
173
+ "alpha": 0.31146526208193726,
174
+ "c": -0.1487809462855659,
175
+ "w_re": -0.192228284814361,
176
+ "w_im": -0.28050912477619155,
177
+ "b_re": -0.19882723914785924,
178
+ "b_im": -0.3321859480783824
179
+ },
180
+ {
181
+ "stage": 17,
182
+ "phasor": 23,
183
+ "step": 0.4,
184
+ "alpha": -0.691962297005292,
185
+ "c": 0.2236726300021271,
186
+ "w_re": -0.3092392733341191,
187
+ "w_im": -0.05190284634462815,
188
+ "b_re": 0.492181263258386,
189
+ "b_im": -0.3103055227978361
190
+ },
191
+ {
192
+ "stage": 18,
193
+ "phasor": 7,
194
+ "step": 0.4,
195
+ "alpha": 0.16678699025239196,
196
+ "c": -0.16240030753693468,
197
+ "w_re": -0.3747982735844348,
198
+ "w_im": 0.20475879054311819,
199
+ "b_re": -0.5659027944892202,
200
+ "b_im": 0.26445255364285103
201
+ },
202
+ {
203
+ "stage": 19,
204
+ "phasor": 20,
205
+ "step": 0.4,
206
+ "alpha": -0.008164434660184142,
207
+ "c": 0.004119182828503224,
208
+ "w_re": 0.03561037285156963,
209
+ "w_im": -0.06353276553290359,
210
+ "b_re": 0.39095817474270683,
211
+ "b_im": 0.19986673296932997
212
+ },
213
+ {
214
+ "stage": 20,
215
+ "phasor": 20,
216
+ "step": 0.4,
217
+ "alpha": -0.030576049347266418,
218
+ "c": 0.0035432005553962245,
219
+ "w_re": 0.02337541497072417,
220
+ "w_im": 0.1005538166637217,
221
+ "b_re": -0.328467058088797,
222
+ "b_im": -0.07539161972439071
223
+ },
224
+ {
225
+ "stage": 21,
226
+ "phasor": 20,
227
+ "step": 0.4,
228
+ "alpha": -0.041398339698101916,
229
+ "c": 0.005465754070426186,
230
+ "w_re": -0.04476609738232852,
231
+ "w_im": 0.16635825257857076,
232
+ "b_re": -0.36087450832523327,
233
+ "b_im": 0.11152443285731284
234
+ },
235
+ {
236
+ "stage": 22,
237
+ "phasor": 20,
238
+ "step": 0.4,
239
+ "alpha": -0.11380137146366145,
240
+ "c": 0.0025476058309119856,
241
+ "w_re": 0.028256324530831264,
242
+ "w_im": 0.11653345741523358,
243
+ "b_re": -0.190184525777227,
244
+ "b_im": 0.03714923525546911
245
+ },
246
+ {
247
+ "stage": 23,
248
+ "phasor": 20,
249
+ "step": 0.4,
250
+ "alpha": 0.07341465288887274,
251
+ "c": -0.0203333671124774,
252
+ "w_re": -0.20880220908325148,
253
+ "w_im": 0.1716401560852639,
254
+ "b_re": 0.1857565062932319,
255
+ "b_im": 0.3755205575990392
256
+ },
257
+ {
258
+ "stage": 24,
259
+ "phasor": 23,
260
+ "step": 0.025,
261
+ "alpha": 0.0025675604826571764,
262
+ "c": 0.0022698323283945818,
263
+ "w_re": 0.011667472114586163,
264
+ "w_im": -0.020929803493994557,
265
+ "b_re": -0.25044814787217995,
266
+ "b_im": -0.0264954757651408
267
+ },
268
+ {
269
+ "stage": 25,
270
+ "phasor": 23,
271
+ "step": 0.4,
272
+ "alpha": 0.3855542289770793,
273
+ "c": -0.13300980650958877,
274
+ "w_re": 0.13279384507466938,
275
+ "w_im": -0.47334365600013123,
276
+ "b_re": -0.5190000169171142,
277
+ "b_im": -0.21912983674650482
278
+ },
279
+ {
280
+ "stage": 26,
281
+ "phasor": 7,
282
+ "step": 0.4,
283
+ "alpha": 0.21921044511589627,
284
+ "c": -0.13913509144130914,
285
+ "w_re": -0.288981646771367,
286
+ "w_im": -0.240467983713006,
287
+ "b_re": -0.3286717045240301,
288
+ "b_im": -0.3357175537050057
289
+ },
290
+ {
291
+ "stage": 27,
292
+ "phasor": 20,
293
+ "step": 0.4,
294
+ "alpha": -0.01729559500497132,
295
+ "c": 0.010603586888993247,
296
+ "w_re": -0.0045418174252236584,
297
+ "w_im": 0.14429496669740433,
298
+ "b_re": -0.7289771467179822,
299
+ "b_im": -0.15110922925003778
300
+ },
301
+ {
302
+ "stage": 28,
303
+ "phasor": 20,
304
+ "step": 0.025,
305
+ "alpha": 0.0029503938352781556,
306
+ "c": -0.00016868347221038905,
307
+ "w_re": 0.031818447407431213,
308
+ "w_im": -0.02925275766963477,
309
+ "b_re": 0.030685508507719773,
310
+ "b_im": 0.3222325568984256
311
+ },
312
+ {
313
+ "stage": 29,
314
+ "phasor": 20,
315
+ "step": 0.4,
316
+ "alpha": -0.023699637641509487,
317
+ "c": 0.0020922555094313056,
318
+ "w_re": 0.07045111449166673,
319
+ "w_im": -0.018005944379729466,
320
+ "b_re": 0.07080146794410015,
321
+ "b_im": 0.31108100653071213
322
+ },
323
+ {
324
+ "stage": 30,
325
+ "phasor": 20,
326
+ "step": 0.4,
327
+ "alpha": -0.10109300198626403,
328
+ "c": 0.009361324814773427,
329
+ "w_re": 0.12728831587213713,
330
+ "w_im": -0.1667525087363923,
331
+ "b_re": 0.22393090550322364,
332
+ "b_im": 0.2799110350682605
333
+ },
334
+ {
335
+ "stage": 31,
336
+ "phasor": 7,
337
+ "step": 0.4,
338
+ "alpha": -0.32826538527588905,
339
+ "c": 0.03560517815619409,
340
+ "w_re": 0.23684967687092165,
341
+ "w_im": -0.16335650143836847,
342
+ "b_re": -0.4854325733136111,
343
+ "b_im": 0.20263486662597754
344
+ }
345
+ ],
346
+ "inner_auc": 0.631962843164345,
347
+ "labels": [
348
+ "a_sun",
349
+ "a_moon",
350
+ "a_mercury",
351
+ "a_venus",
352
+ "a_mars",
353
+ "a_jupiter",
354
+ "a_saturn",
355
+ "a_uranus",
356
+ "a_neptune",
357
+ "a_pluto",
358
+ "a_true_node",
359
+ "a_true_south_node",
360
+ "a_chiron",
361
+ "a_mean_lilith",
362
+ "m_sun",
363
+ "m_moon",
364
+ "m_mercury",
365
+ "m_venus",
366
+ "m_mars",
367
+ "m_jupiter",
368
+ "m_saturn",
369
+ "m_uranus",
370
+ "m_neptune",
371
+ "m_pluto",
372
+ "m_true_node",
373
+ "m_true_south_node",
374
+ "m_chiron",
375
+ "m_mean_lilith",
376
+ "d_sun",
377
+ "d_moon",
378
+ "d_mercury",
379
+ "d_venus",
380
+ "d_mars",
381
+ "d_jupiter",
382
+ "d_saturn",
383
+ "d_uranus",
384
+ "d_neptune",
385
+ "d_pluto",
386
+ "d_true_node",
387
+ "d_true_south_node",
388
+ "d_chiron",
389
+ "d_mean_lilith",
390
+ "mn_sun",
391
+ "mn_moon",
392
+ "mn_mercury",
393
+ "mn_venus",
394
+ "mn_mars",
395
+ "mn_jupiter",
396
+ "mn_saturn",
397
+ "mn_uranus",
398
+ "mn_neptune",
399
+ "mn_pluto",
400
+ "mn_true_node",
401
+ "mn_true_south_node",
402
+ "mn_chiron",
403
+ "mn_mean_lilith",
404
+ "dn_sun",
405
+ "dn_moon",
406
+ "dn_mercury",
407
+ "dn_venus",
408
+ "dn_mars",
409
+ "dn_jupiter",
410
+ "dn_saturn",
411
+ "dn_uranus",
412
+ "dn_neptune",
413
+ "dn_pluto",
414
+ "dn_true_node",
415
+ "dn_true_south_node",
416
+ "dn_chiron",
417
+ "dn_mean_lilith",
418
+ "tn_sun",
419
+ "tn_moon",
420
+ "tn_mercury",
421
+ "tn_venus",
422
+ "tn_mars",
423
+ "tn_jupiter",
424
+ "tn_saturn",
425
+ "tn_uranus",
426
+ "tn_neptune",
427
+ "tn_pluto",
428
+ "tn_true_node",
429
+ "tn_true_south_node",
430
+ "tn_chiron",
431
+ "tn_mean_lilith"
432
+ ],
433
+ "fitted_on": "train rows with both natal charts",
434
+ "n_rows": 9553
435
+ }
artamodel_score.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ artamodel_score.py — score a couple with the deployed ArtaModel, from three dates and two places. numpy only for
3
+ the model; Kerykeion for the phases (pip install kerykeion timezonefinder).
4
+
5
+ from artamodel_score import predict
6
+ p = predict("1936-08-04", 37.943, 23.647, "1924-05-14", 37.727, 26.909, "1968-06-15") # dad, mom, wedding
7
+
8
+ Returns the probability that the marriage lasted thirty years, and the term-by-term account of how the logit was
9
+ formed. See README.md / ARTAMODEL.md for what the model is and, honestly, what it reads.
10
+ """
11
+ import json
12
+ import os
13
+ import warnings
14
+
15
+ import numpy as np
16
+
17
+ warnings.filterwarnings("ignore")
18
+ HERE = os.path.dirname(os.path.abspath(__file__))
19
+ MODEL = json.load(open(os.path.join(HERE, "artamodel_deployed.json")))
20
+ BODIES = MODEL["bodies"]; TERMS = MODEL["terms"]; LABELS = MODEL["labels"]
21
+ ANGLES = {"ascendant", "medium_coeli"}
22
+ SLOW = {"jupiter", "saturn", "uranus", "neptune", "pluto", "true_node", "true_south_node", "chiron", "mean_lilith"}
23
+ _TF = None
24
+
25
+
26
+ def _tz(lat, lon):
27
+ global _TF
28
+ if _TF is None:
29
+ from timezonefinder import TimezoneFinder
30
+ _TF = TimezoneFinder()
31
+ return _TF.timezone_at(lng=lon, lat=lat) or "UTC"
32
+
33
+
34
+ def _prec(d):
35
+ if not d or d == "0000-00-00":
36
+ return 0
37
+ return 1 if d.endswith("-00-00") else (2 if d.endswith("-00") else 3)
38
+
39
+
40
+ def theta(date, lat=None, lon=None, hour=9, natal=True):
41
+ """Sidereal (Lahiri) longitudes of the model's bodies at 09:00 local (natal) or 12:00 UT (wedding); NaN where
42
+ the date's precision cannot place a body (year-only -> slow bodies only)."""
43
+ from kerykeion import AstrologicalSubject
44
+ out = np.full(len(BODIES), np.nan); p = _prec(date)
45
+ if p == 0 or (natal and (lat is None or lon is None)):
46
+ return out
47
+ y, m, d = int(date[:4]), max(1, int(date[5:7])), max(1, int(date[8:10]))
48
+ if natal:
49
+ s = AstrologicalSubject("x", y, m, d, hour, 0, lng=float(lon), lat=float(lat), tz_str=_tz(float(lat), float(lon)),
50
+ city="x", nation="XX", zodiac_type="Sidereal", sidereal_mode="LAHIRI", online=False)
51
+ else:
52
+ s = AstrologicalSubject("w", y, m, d, 12, 0, lng=0.0, lat=51.48, tz_str="UTC", city="G", nation="GB",
53
+ zodiac_type="Sidereal", sidereal_mode="LAHIRI", online=False)
54
+ for j, b in enumerate(BODIES):
55
+ if p == 1 and b not in SLOW:
56
+ continue
57
+ if p == 2 and b not in SLOW and b != "sun":
58
+ continue
59
+ try:
60
+ out[j] = float(getattr(s, b).abs_pos)
61
+ except Exception:
62
+ pass
63
+ return out
64
+
65
+
66
+ def phases(td, tm, tw):
67
+ """The 84 phases in the model's label order (NaN = the term does not exist)."""
68
+ P = np.full(len(LABELS), np.nan)
69
+ col = {b: j for j, b in enumerate(BODIES)}
70
+ for k, lab in enumerate(LABELS):
71
+ t, b = lab.split("_", 1); j = col[b]
72
+ if t == "a": P[k] = tm[j] - td[j]
73
+ elif t == "m": P[k] = tw[j] - tm[j]
74
+ elif t == "d": P[k] = tw[j] - td[j]
75
+ elif t == "mn": P[k] = tm[j]
76
+ elif t == "dn": P[k] = td[j]
77
+ elif t == "tn": P[k] = tw[j]
78
+ return P
79
+
80
+
81
+ def logit(P):
82
+ """The deployed model's logit for one phase vector, with the per-stage account."""
83
+ rad = np.pi / 180.0
84
+ C, S = np.nan_to_num(np.cos(P * rad)), np.nan_to_num(np.sin(P * rad))
85
+ F = MODEL["F0"]; account = []
86
+ for st in MODEL["stages"]:
87
+ j = st["phasor"]
88
+ if not np.isfinite(P[j]):
89
+ account.append({"stage": st["stage"], "phasor": LABELS[j], "contribution": 0.0, "note": "term absent for this couple"}); continue
90
+ Zr = st["w_re"] * C[j] - st["w_im"] * S[j] + st["b_re"]; Zi = st["w_re"] * S[j] + st["w_im"] * C[j] + st["b_im"]
91
+ u = Zr * Zr + Zi * Zi; contrib = st["step"] * (st["alpha"] * u + st["c"])
92
+ F += contrib; account.append({"stage": st["stage"], "phasor": LABELS[j], "phase_deg": float(P[j] % 360), "contribution": float(contrib)})
93
+ return float(F), account
94
+
95
+
96
+ def predict(dob_dad, lat_dad, lon_dad, dob_mom, lat_mom, lon_mom, start):
97
+ wed = start if start[5:] != "01-01" else start[:4] + "-00-00" # a 1 January start is a year-only record
98
+ P = phases(theta(dob_dad, lat_dad, lon_dad), theta(dob_mom, lat_mom, lon_mom), theta(wed, natal=False))
99
+ F, account = logit(P)
100
+ return {"probability": float(1 / (1 + np.exp(-F))), "logit": F, "terms": account}
101
+
102
+
103
+ if __name__ == "__main__":
104
+ r = predict("1936-08-04", 37.943, 23.647, "1924-05-14", 37.727, 26.909, "1968-06-15")
105
+ print(f" p(lasted 30 years) = {r['probability']:.3f} logit {r['logit']:+.3f}")
106
+ for t in r["terms"][:8]:
107
+ print(f" stage {t['stage']:>2} {t['phasor']:<12} " + (f"φ={t['phase_deg']:6.1f}° {t['contribution']:+.4f}" if "phase_deg" in t else t["note"]))
artamodel_selected.json ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "E1 term subsets (F=1)": {
3
+ "n_configs": 70,
4
+ "selected_by_inner": "E1 terms m+d",
5
+ "its_inner": 0.6426429599219167,
6
+ "its_held": 0.6303868028308671,
7
+ "max_held_in_family": 0.6318152802643866,
8
+ "optimism_if_selected_on_held": 0.0014284774335194816
9
+ },
10
+ "E1 F=8 rungs": {
11
+ "n_configs": 6,
12
+ "selected_by_inner": "E1 F8 terms a+m+d",
13
+ "its_inner": 0.6479533311196305,
14
+ "its_held": 0.621263016459523,
15
+ "max_held_in_family": 0.6310528484705769,
16
+ "optimism_if_selected_on_held": 0.009789832011053878
17
+ },
18
+ "E3 3-term body sets": {
19
+ "n_configs": 45,
20
+ "selected_by_inner": "E3 3-term drop chiron",
21
+ "its_inner": 0.6488664835330697,
22
+ "its_held": 0.6245990162840656,
23
+ "max_held_in_family": 0.6419098541279247,
24
+ "optimism_if_selected_on_held": 0.017310837843859117
25
+ },
26
+ "E3 6-term body sets": {
27
+ "n_configs": 45,
28
+ "selected_by_inner": "E3 6-term drop outer",
29
+ "its_inner": 0.6290387498616435,
30
+ "its_held": 0.584257082073676,
31
+ "max_held_in_family": 0.6404028825808864,
32
+ "optimism_if_selected_on_held": 0.05614580050721041
33
+ },
34
+ "E5 harmonics": {
35
+ "n_configs": 6,
36
+ "selected_by_inner": "E5 harmonic 2 terms a+m+d",
37
+ "its_inner": 0.6284878397279158,
38
+ "its_held": 0.628358653456569,
39
+ "max_held_in_family": 0.628358653456569,
40
+ "optimism_if_selected_on_held": 0.0
41
+ },
42
+ "E6 fields x L2": {
43
+ "n_configs": 28,
44
+ "selected_by_inner": "E6 3-term F=64 l2=0.01",
45
+ "its_inner": 0.651917871625361,
46
+ "its_held": 0.6163225046086434,
47
+ "max_held_in_family": 0.6364966461081348,
48
+ "optimism_if_selected_on_held": 0.020174141499491482
49
+ },
50
+ "E9 conventions, 3-term": {
51
+ "n_configs": 10,
52
+ "selected_by_inner": "E9 hour 12:00 UT, place ignored 3-term",
53
+ "its_inner": 0.6417071673659955,
54
+ "its_held": 0.6258785857669394,
55
+ "max_held_in_family": 0.625995749774618,
56
+ "optimism_if_selected_on_held": 0.00011716400767858293
57
+ },
58
+ "E9 conventions, 6-term": {
59
+ "n_configs": 10,
60
+ "selected_by_inner": "E9 TROPICAL 6-term",
61
+ "its_inner": 0.6041144685603888,
62
+ "its_held": 0.5941404144258329,
63
+ "max_held_in_family": 0.5941404144258329,
64
+ "optimism_if_selected_on_held": 0.0
65
+ }
66
+ }
artamodel_study.json ADDED
The diff for this file is too large to render. See raw diff
 
coherent_fit.py ADDED
@@ -0,0 +1,331 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ coherent_fit.py — FIT the coherent phasor field. Arash's form, with a, p and b learned rather than drawn.
3
+
4
+ F_f(chart) = | b_f + SUM_k w_fk * exp( i * h_k * theta_k ) |^2 , w_fk = a_fk * exp(-i * p_fk)
5
+
6
+ THE REPARAMETERISATION, AND WHY IT MATTERS. Written in polar form the parameters are an amplitude a >= 0 and a
7
+ phase p on a circle, and a gradient step on p has to be wrapped. Folding them into ONE COMPLEX WEIGHT
8
+ w = a*exp(-i*p) removes both problems: the model becomes a complex linear map followed by a squared modulus, the
9
+ parameter space is flat R^2 per term, and every gradient is a matmul. With A1 = Re w and A2 = -Im w,
10
+
11
+ Re S = C @ A1' + S_ @ A2' Im S = S_ @ A1' - C @ A2'
12
+
13
+ where C = cos(h*theta) and S_ = sin(h*theta) are the batch's basis. Nothing is approximated -- this is the same
14
+ function, in coordinates that optimise cleanly.
15
+
16
+ WHAT IS ACTUALLY BEING LEARNED. |b + sum w_k z_k|^2 expands to |b|^2 + 2*Re(b_bar * sum w_k z_k) + sum_jk
17
+ w_j w_k_bar z_j z_k_bar. The last term is a QUADRATIC form in the chart's Fourier coefficients, so a bank of F
18
+ fields is a rank-F quadratic model over every pairwise product of body phases -- every classical aspect, every
19
+ harmonic of one, and every cross-partner contact, simultaneously, with the weights fitted. The bias term is what
20
+ makes the field's response asymmetric rather than a pure interference pattern.
21
+
22
+ The basis spans harmonics h in HARMONICS for every (partner-slot, body) pair, so a single field may mix
23
+ harmonics. That is strictly more general than one harmonic per field, and it is the fit's business which to use.
24
+
25
+ INNER VALIDATION IS TEMPORAL, NOT RANDOM. Early stopping on a random slice of the training half would select the
26
+ iteration that generalises best to CONTEMPORARIES of the training data, which is not the question -- the real
27
+ test is out of time. So the inner split holds out the LATEST births of the training half, mirroring the outer
28
+ split. A random inner split measurably overfits the epoch count here.
29
+
30
+ THE ONLY COMPARISON REPORTED is the two-parameter logistic on the age gap. Note a consequence of the dataset's
31
+ own column definition: with the OLDER partner always first, the gap is non-negative by construction, so the
32
+ signed form that distinguished man-older from woman-older is not expressible and is not claimed -- no sex is
33
+ read anywhere in this dataset.
34
+
35
+ Usage: AQ_LON=/tmp/aqcoh/lon.npz AQ_SET=fast AQ_FIELDS=64 python coherent_fit.py
36
+ """
37
+ import json
38
+ import os
39
+ import sys
40
+ import time
41
+
42
+ import numpy as np
43
+
44
+ T0 = time.time()
45
+
46
+ HARMONICS = (1, 2, 3, 4, 6, 8, 12)
47
+ SUN, MOON, MERCURY, VENUS, MARS, JUPITER, SATURN, URANUS, NEPTUNE, PLUTO = range(10)
48
+ SETS = {
49
+ "fast": (SUN, MOON, MERCURY, VENUS, MARS),
50
+ "classical": (SUN, MOON, MERCURY, VENUS, MARS, JUPITER, SATURN),
51
+ "all18": tuple(range(18)),
52
+ }
53
+ iO, iY = 0, 1
54
+
55
+
56
+ def log(*a):
57
+ print(f"[{time.time()-T0:6.1f}s]", *a, flush=True)
58
+
59
+
60
+ def auc(y, s):
61
+ y = np.asarray(y, np.int64)
62
+ s = np.asarray(s, np.float64)
63
+ n1, n0 = int(y.sum()), int((1 - y).sum())
64
+ if n1 == 0 or n0 == 0:
65
+ return float("nan")
66
+ o = np.argsort(s, kind="mergesort")
67
+ ys, ss = y[o], s[o]
68
+ r = np.empty(len(ss))
69
+ i = 0
70
+ while i < len(ss):
71
+ j = i
72
+ while j + 1 < len(ss) and ss[j + 1] == ss[i]:
73
+ j += 1
74
+ r[i:j + 1] = 0.5 * (i + j) + 1.0
75
+ i = j + 1
76
+ return float((r[ys == 1].sum() - n1 * (n1 + 1) / 2.0) / (n1 * n0))
77
+
78
+
79
+ # Mean daily motion in degrees, for the orb filter below. Only the ten classical/modern bodies are ever used
80
+ # with a harmonic filter; the asteroids and nodes are slow and unaffected.
81
+ DAILY = {SUN: 0.9856, MOON: 13.176, MERCURY: 4.09, VENUS: 1.60, MARS: 0.524, JUPITER: 0.083,
82
+ SATURN: 0.0335, URANUS: 0.0117, NEPTUNE: 0.006, PLUTO: 0.004}
83
+
84
+
85
+ def basis(LON, bodies, orb=0.0):
86
+ """cos and sin of h*theta for every (slot, body, harmonic), with an ORB FILTER on the harmonic.
87
+
88
+ WHY A FILTER. This dataset has birth DATES and no birth TIMES, so every chart is cast for a fixed hour and
89
+ each body carries an uncertainty of +-(daily motion)/2 degrees. A harmonic MULTIPLIES that error: the Moon
90
+ moves 13.2 deg/day, so its phase is +-6.6 deg at h=1 and +-79 deg at h=12 -- at which point the term is not a
91
+ weak feature, it is noise with a plausible name, and fitting it can only cost generalisation.
92
+
93
+ `orb` is the largest phase error in degrees a term may carry. A term is admitted when
94
+ h * (daily motion)/2 <= orb. orb=0 admits everything (the unfiltered basis); orb=30 drops the Moon above
95
+ h=4 and Mercury above h=12 while keeping every slow body at every harmonic.
96
+ """
97
+ rad = np.pi / 180.0
98
+ cols, kept = [], []
99
+ for h in HARMONICS:
100
+ for b in bodies:
101
+ if orb and h * DAILY.get(b, 0.3) / 2.0 > orb:
102
+ continue
103
+ for s in (iO, iY):
104
+ cols.append(h * LON[s, b] * rad)
105
+ kept.append((h, b, s))
106
+ P = np.stack(cols, axis=1)
107
+ return np.cos(P), np.sin(P), kept
108
+
109
+
110
+ class Coherent:
111
+ """A bank of F coherent fields plus a logistic head, fitted by Adam on the exact gradients."""
112
+
113
+ def __init__(self, K, F=64, seed=0):
114
+ g = np.random.default_rng(seed)
115
+ # Small init so the initial |Z|^2 is O(1) and the head starts near chance.
116
+ sc = 1.0 / np.sqrt(K)
117
+ self.A1 = g.normal(0, sc, (F, K))
118
+ self.A2 = g.normal(0, sc, (F, K))
119
+ self.br = g.normal(0, 0.3, F)
120
+ self.bi = g.normal(0, 0.3, F)
121
+ self.w = np.zeros(F)
122
+ self.c = 0.0
123
+ self.mu = np.zeros(F)
124
+ self.sd = np.ones(F)
125
+ self.F, self.K = F, K
126
+ self._m = {k: np.zeros_like(getattr(self, k)) for k in ("A1", "A2", "br", "bi", "w")}
127
+ self._v = {k: np.zeros_like(getattr(self, k)) for k in ("A1", "A2", "br", "bi", "w")}
128
+ self._mc = self._vc = 0.0
129
+ self.t = 0
130
+
131
+ def fields(self, C, S):
132
+ ReS = C @ self.A1.T + S @ self.A2.T
133
+ ImS = S @ self.A1.T - C @ self.A2.T
134
+ Zr, Zi = ReS + self.br, ImS + self.bi
135
+ return Zr, Zi, Zr * Zr + Zi * Zi
136
+
137
+ def logit(self, C, S):
138
+ Zr, Zi, u = self.fields(C, S)
139
+ return ((u - self.mu) / self.sd) @ self.w + self.c, Zr, Zi, u
140
+
141
+ def step(self, C, S, y, lr, l2, mom=0.99):
142
+ B = len(y)
143
+ Zr, Zi, u = self.fields(C, S)
144
+ # Running standardisation of the fields. Treated as a constant in the gradient -- the standard
145
+ # inference-time treatment, and the running stats are what the held-out pass will use.
146
+ self.mu = mom * self.mu + (1 - mom) * u.mean(0)
147
+ self.sd = mom * self.sd + (1 - mom) * (u.std(0) + 1e-6)
148
+ un = (u - self.mu) / self.sd
149
+ z = un @ self.w + self.c
150
+ p = 1.0 / (1.0 + np.exp(-np.clip(z, -30, 30)))
151
+ d = (p - y) / B # dL/dz
152
+ gw = un.T @ d + l2 * self.w
153
+ gc = d.sum()
154
+ gu = np.outer(d, self.w) / self.sd # dL/du (B,F)
155
+ gr, gi = 2.0 * gu * Zr, 2.0 * gu * Zi # dL/dReZ, dL/dImZ
156
+ gA1 = gr.T @ C + gi.T @ S + l2 * self.A1
157
+ gA2 = gr.T @ S - gi.T @ C + l2 * self.A2
158
+ gbr, gbi = gr.sum(0), gi.sum(0)
159
+ self.t += 1
160
+ for k, g in (("A1", gA1), ("A2", gA2), ("br", gbr), ("bi", gbi), ("w", gw)):
161
+ self._m[k] = 0.9 * self._m[k] + 0.1 * g
162
+ self._v[k] = 0.999 * self._v[k] + 0.001 * g * g
163
+ mh = self._m[k] / (1 - 0.9 ** self.t)
164
+ vh = self._v[k] / (1 - 0.999 ** self.t)
165
+ setattr(self, k, getattr(self, k) - lr * mh / (np.sqrt(vh) + 1e-8))
166
+ self.c -= lr * gc
167
+ return float(-np.mean(y * np.log(p + 1e-12) + (1 - y) * np.log(1 - p + 1e-12)))
168
+
169
+
170
+ def _gradcheck():
171
+ """The exact gradients against finite differences. If this is wrong every number below is noise."""
172
+ g = np.random.default_rng(0)
173
+ B, K, F = 24, 9, 4
174
+ C, S = g.normal(size=(B, K)), g.normal(size=(B, K))
175
+ y = (g.random(B) < 0.5).astype(float)
176
+ m = Coherent(K, F, seed=1)
177
+ m.w = g.normal(0, 0.5, F)
178
+ m.mu, m.sd = np.zeros(F), np.ones(F)
179
+
180
+ def loss():
181
+ _, _, u = m.fields(C, S)
182
+ z = ((u - m.mu) / m.sd) @ m.w + m.c
183
+ p = 1 / (1 + np.exp(-z))
184
+ return float(-np.mean(y * np.log(p) + (1 - y) * np.log(1 - p)))
185
+
186
+ # analytic, with the running-stat update and the optimiser disabled
187
+ B_ = len(y)
188
+ Zr, Zi, u = m.fields(C, S)
189
+ z = ((u - m.mu) / m.sd) @ m.w + m.c
190
+ p = 1 / (1 + np.exp(-z))
191
+ d = (p - y) / B_
192
+ gu = np.outer(d, m.w) / m.sd
193
+ gr, gi = 2 * gu * Zr, 2 * gu * Zi
194
+ an = {"A1": gr.T @ C + gi.T @ S, "A2": gr.T @ S - gi.T @ C, "br": gr.sum(0), "bi": gi.sum(0)}
195
+ worst = 0.0
196
+ for name in an:
197
+ P = getattr(m, name)
198
+ num = np.zeros_like(P)
199
+ it = np.nditer(P, flags=["multi_index"])
200
+ for _ in it:
201
+ ix = it.multi_index
202
+ o = P[ix]
203
+ P[ix] = o + 1e-6
204
+ hi = loss()
205
+ P[ix] = o - 1e-6
206
+ lo = loss()
207
+ P[ix] = o
208
+ num[ix] = (hi - lo) / 2e-6
209
+ rel = np.abs(num - an[name]).max() / max(1e-12, np.abs(num).max())
210
+ worst = max(worst, rel)
211
+ assert rel < 2e-4, (name, rel)
212
+ print(f" gradcheck: analytic == finite-difference for A1, A2, Re b, Im b (worst relative {worst:.2e})")
213
+
214
+
215
+ def main():
216
+ Z = np.load(os.environ.get("AQ_LON", "/tmp/aqcoh/lon.npz"))
217
+ LONtr, LONte = Z["lon_train"], Z["lon_test"]
218
+ ytr, yte = Z["y_train"], Z["y_test"]
219
+ yr_tr, yr_te = Z["yr_train"], Z["yr_test"] # (2, n) the two birth years
220
+ which = os.environ.get("AQ_SET", "fast")
221
+ F = int(os.environ.get("AQ_FIELDS") or 64)
222
+ EPOCHS = int(os.environ.get("AQ_EPOCHS") or 40)
223
+ LR = float(os.environ.get("AQ_LR") or 0.01)
224
+ L2 = float(os.environ.get("AQ_L2") or 1e-4)
225
+ SEEDS = int(os.environ.get("AQ_SEEDS") or 3)
226
+ TRACE = bool(os.environ.get("AQ_TRACE"))
227
+ PATIENCE = int(os.environ.get("AQ_PATIENCE") or 12)
228
+ bodies = SETS[which]
229
+ TWO = os.environ.get("AQ_TWO_SIDED", "1") not in ("0", "")
230
+
231
+ # DROP THE IDENTICAL-CHART ROWS FROM THE FIT, BY DEFAULT.
232
+ #
233
+ # dates.couple_record gives a partner with no known birth date the OTHER partner's instant, deliberately and
234
+ # documented -- every chart needs some instant, and self-comparison is a defined value rather than a guess
235
+ # about a stranger. For a one-sided feature that is harmless. For a COHERENT SUM OVER BOTH CHARTS it is not:
236
+ # when theta_older == theta_younger the sum collapses from SUM_k (w_Ok e^{ih th_Ok} + w_Yk e^{ih th_Yk}) to
237
+ # SUM_k (w_Ok + w_Yk) e^{ih th_k}, a different and smaller function class. Measured on this data that is
238
+ # 41.3% of training rows and 0.1% of held-out rows -- so 41% of the fit's gradient comes from a configuration
239
+ # that essentially never occurs at test time, and the fitted phases are pulled toward it.
240
+ #
241
+ # This is not specific to this module. Every cross-chart block in the stack -- ashtakoot, the Uranian dial
242
+ # distances, the composite and Davison charts -- is degenerate on the same 41% and was fitted through it.
243
+ if TWO:
244
+ keep = ~np.all(np.isclose(LONtr[0], LONtr[1], atol=1e-4), axis=0)
245
+ log(f" genuine pairs only: {int(keep.sum()):,} of {len(ytr):,} training rows "
246
+ f"({100*(~keep).mean():.1f}% dropped as identical-chart)")
247
+ LONtr, ytr, yr_tr = LONtr[:, :, keep], ytr[keep], yr_tr[:, keep]
248
+
249
+ ORB = float(os.environ.get('AQ_ORB') or 0)
250
+ Ctr, Str, kept = basis(LONtr, bodies, ORB)
251
+ Cte, Ste, _ = basis(LONte, bodies, ORB)
252
+ K = Ctr.shape[1]
253
+ log(f"set {which}: {len(bodies)} bodies, orb {ORB:g} deg -> {K} basis terms · "
254
+ f"{F} fields · train {len(ytr):,} · held out {len(yte):,}")
255
+
256
+ # TEMPORAL inner split: the latest births of the training half become the inner validation set.
257
+ later = yr_tr.max(0)
258
+ cutoff = np.quantile(later, 0.85)
259
+ inner = later > cutoff
260
+ log(f" inner validation = training births after {cutoff:.0f} ({inner.sum():,} rows), a TEMPORAL split")
261
+
262
+ best_te = None
263
+ aucs = []
264
+ for seed in range(SEEDS):
265
+ m = Coherent(K, F, seed=seed)
266
+ rng = np.random.default_rng(1000 + seed)
267
+ fit = ~inner
268
+ idx = np.where(fit)[0]
269
+ best_iv, best_state, bad = -1, None, 0
270
+ for ep in range(EPOCHS):
271
+ rng.shuffle(idx)
272
+ for s in range(0, len(idx), 4096):
273
+ b = idx[s:s + 4096]
274
+ if len(b) < 64:
275
+ continue
276
+ m.step(Ctr[b], Str[b], ytr[b].astype(float), LR, L2)
277
+ ziv, _, _, _ = m.logit(Ctr[inner], Str[inner])
278
+ a = auc(ytr[inner], ziv)
279
+ if TRACE:
280
+ log(f" seed {seed} epoch {ep:>3} inner {a:.4f}")
281
+ if a > best_iv + 1e-5:
282
+ best_iv, bad = a, 0
283
+ best_state = (m.A1.copy(), m.A2.copy(), m.br.copy(), m.bi.copy(), m.w.copy(),
284
+ m.c, m.mu.copy(), m.sd.copy())
285
+ else:
286
+ bad += 1
287
+ if bad >= PATIENCE:
288
+ break
289
+ m.A1, m.A2, m.br, m.bi, m.w, m.c, m.mu, m.sd = best_state
290
+ zte, _, _, ute = m.logit(Cte, Ste)
291
+ a_te = auc(yte, zte)
292
+ aucs.append(a_te)
293
+ log(f" seed {seed}: inner (temporal) {best_iv:.4f} -> HELD OUT {a_te:.4f}")
294
+ if best_te is None or best_iv > best_te[0]:
295
+ best_te = (best_iv, zte, ute, m)
296
+
297
+ # THE ONE PERMITTED COMPARISON: the two-parameter logistic on the age gap.
298
+ #
299
+ # It needs no fitting. b0 + b1*gap is MONOTONE in gap, and AUC is invariant under any monotone transform of
300
+ # the score, so the logistic's AUC is exactly max(AUC(gap), 1 - AUC(gap)) -- the sign of b1 being the only
301
+ # thing the fit decides. The gradient-descent version of this overflowed np.exp and reported the same number
302
+ # less reliably.
303
+ #
304
+ # Note what the dataset's own column definition does to this baseline: with the OLDER partner always first,
305
+ # the gap is non-negative by construction, so the SIGNED form that once distinguished man-older from
306
+ # woman-older is not expressible here. No sex is read anywhere in this dataset.
307
+ gap_te = (yr_te[1] - yr_te[0]).astype(float)
308
+ absent = (yr_te[0] == 0) | (yr_te[1] == 0)
309
+ assert not absent.any(), "a held-out row with an absent partner has no age gap"
310
+ g = auc(yte, gap_te)
311
+ gap_auc = max(g, 1 - g)
312
+
313
+ mean, sd = float(np.mean(aucs)), float(np.std(aucs))
314
+ print(f"\n COHERENT FIELD, set '{which}', {F} fields over {K} basis terms")
315
+ print(f" held-out AUC over {SEEDS} seeds: {mean:.4f} +- {sd:.4f} (best-inner seed {auc(yte, best_te[1]):.4f})")
316
+ print(f" age-gap logistic (2 parameters), same rows: {gap_auc:.4f}")
317
+ out = {"set": which, "fields": F, "basis": K, "held_out_mean": mean, "held_out_sd": sd,
318
+ "seeds": [float(a) for a in aucs], "age_gap": gap_auc}
319
+ d = os.environ.get("AQ_OUT", "/tmp/aqcoh")
320
+ json.dump(out, open(os.path.join(d, f"coherent_{which}.json"), "w"), indent=1)
321
+ np.savez_compressed(os.path.join(d, f"coherent_{which}_fields.npz"),
322
+ test_fields=best_te[2].astype(np.float32))
323
+ print(f" wrote {d}/coherent_{which}.json")
324
+
325
+
326
+ if __name__ == "__main__":
327
+ if os.environ.get("AQ_GRADCHECK") or "--gradcheck" in sys.argv:
328
+ _gradcheck()
329
+ else:
330
+ _gradcheck()
331
+ main()
kerykeion_phases.py ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ kerykeion_phases.py — the PHASE theta of every body at every time, through Kerykeion (sidereal, Lahiri).
3
+
4
+ Operator, 2026-08-18: "use kerykeion for getting the phase of each body at each time". Three instants per couple:
5
+ dad's birth and mom's birth at 09:00 LOCAL at each birthplace (time zone from the coordinates through
6
+ timezonefinder; Kerykeion converts to UT and casts, houses included), and the wedding at 12:00 UT (no wedding
7
+ place is in the data, so no wedding houses).
8
+
9
+ BODIES: Sun Moon Mercury Venus Mars Jupiter Saturn Uranus Neptune Pluto TrueNode(Rahu) TrueSouthNode(Ketu) Chiron
10
+ MeanLilith, and for the natal charts the Ascendant and MC. Precision-aware like everything else here: a
11
+ year-only birth (1st of January placeholder) gets only the bodies the year can place -- Jupiter and slower --
12
+ and no angles; a month-only birth adds the Sun; a year-only wedding gets the slow bodies only.
13
+
14
+ Writes AQ_OUT/phases.npz: theta_dad, theta_mom, theta_wed (rows x bodies, degrees, NaN where undefined),
15
+ bodies, y_train / ids, the plain columns for references. Kerykeion agrees with PyJHora to 0.005 degrees on the
16
+ same instant (checked); ~0.8 ms per chart, so the whole build is minutes.
17
+ """
18
+ import datetime as dt
19
+ import multiprocessing as mp
20
+ import os
21
+ import sys
22
+ import time
23
+ import warnings
24
+
25
+ import numpy as np
26
+ import pandas as pd
27
+
28
+ warnings.filterwarnings("ignore")
29
+ SRC = os.environ.get("AQ_SRC", "/tmp/aq3")
30
+ OUT = os.environ.get("AQ_OUT", "/tmp/aq3feat")
31
+ LIMIT = int(os.environ.get("AQ_LIMIT") or 0)
32
+ BODIES = ["sun", "moon", "mercury", "venus", "mars", "jupiter", "saturn", "uranus", "neptune", "pluto",
33
+ "true_node", "true_south_node", "chiron", "mean_lilith", "ascendant", "medium_coeli"]
34
+ SLOW = {"jupiter", "saturn", "uranus", "neptune", "pluto", "true_node", "true_south_node", "chiron", "mean_lilith"}
35
+ ANGLES = {"ascendant", "medium_coeli"}
36
+ T0 = time.time()
37
+
38
+
39
+ def log(*a):
40
+ print(f"[{time.time()-T0:6.1f}s]", *a, flush=True)
41
+
42
+
43
+ _TF = None
44
+ _TZC = {}
45
+
46
+
47
+ def tz_name(lat, lon):
48
+ global _TF
49
+ if _TF is None:
50
+ from timezonefinder import TimezoneFinder
51
+ _TF = TimezoneFinder()
52
+ key = (round(lat, 2), round(lon, 2))
53
+ if key not in _TZC:
54
+ _TZC[key] = _TF.timezone_at(lng=lon, lat=lat) or _TF.closest_timezone_at(lng=lon, lat=lat) or "UTC"
55
+ return _TZC[key]
56
+
57
+
58
+ def prec(dob):
59
+ if not dob or dob == "0000-00-00" or dob == "nan":
60
+ return 0
61
+ return 1 if dob.endswith("-00-00") else (2 if dob.endswith("-00") else 3)
62
+
63
+
64
+ def theta(dob, lat, lon, hour, natal):
65
+ """Longitudes of BODIES at (dob, hour local at lat/lon), NaN where the date's precision cannot place them."""
66
+ from kerykeion import AstrologicalSubject
67
+ out = np.full(len(BODIES), np.nan)
68
+ p = prec(dob)
69
+ if p == 0:
70
+ return out
71
+ if natal and (lat is None or (isinstance(lat, float) and np.isnan(lat))):
72
+ return out
73
+ y, m, d = int(dob[:4]), max(1, int(dob[5:7])), max(1, int(dob[8:10]))
74
+ try:
75
+ if natal:
76
+ s = AstrologicalSubject("x", y, m, d, hour, 0, lng=float(lon), lat=float(lat), tz_str=tz_name(float(lat), float(lon)),
77
+ city="x", nation="XX", zodiac_type="Sidereal", sidereal_mode="LAHIRI", online=False)
78
+ else:
79
+ s = AstrologicalSubject("w", y, m, d, hour, 0, lng=0.0, lat=51.48, tz_str="UTC", city="Greenwich", nation="GB",
80
+ zodiac_type="Sidereal", sidereal_mode="LAHIRI", online=False)
81
+ except Exception:
82
+ return out
83
+ for j, b in enumerate(BODIES):
84
+ if b in ANGLES and (not natal or p < 3):
85
+ continue
86
+ if p == 1 and b not in SLOW:
87
+ continue
88
+ if p == 2 and b not in SLOW and b != "sun":
89
+ continue
90
+ try:
91
+ out[j] = float(getattr(s, b).abs_pos)
92
+ except Exception:
93
+ pass
94
+ return out
95
+
96
+
97
+ def _work(args):
98
+ i, dd, latd, lond, dm, latm, lonm, start = args
99
+ wed = start if start[5:] != "01-01" else start[:4] + "-00-00" # a 1 January start is a year-only record
100
+ return i, theta(dd, latd, lond, 9, True), theta(dm, latm, lonm, 9, True), theta(wed, None, None, 12, False)
101
+
102
+
103
+ def build(df):
104
+ jobs = [(i, r.dob_dad, r.lat_dad, r.lon_dad, r.dob_mom, r.lat_mom, r.lon_mom, r.start)
105
+ for i, r in enumerate(df.itertuples(index=False))]
106
+ with mp.Pool(max(1, mp.cpu_count() - 1)) as pool:
107
+ res = pool.map(_work, jobs, chunksize=256)
108
+ n = len(df); D = np.full((n, len(BODIES)), np.nan); M = D.copy(); W = D.copy()
109
+ for i, a, b, c in res:
110
+ D[i], M[i], W[i] = a, b, c
111
+ return D, M, W
112
+
113
+
114
+ def main():
115
+ tr = pd.read_csv(f"{SRC}/train.csv", dtype={"dob_dad": str, "dob_mom": str, "start": str})
116
+ te = pd.read_csv(f"{SRC}/test.csv", dtype={"dob_dad": str, "dob_mom": str, "start": str})
117
+ LABEL = [c for c in tr.columns if c not in {"id", "dob_dad", "dob_mom", "lat_dad", "lon_dad", "lat_mom", "lon_mom", "start"}][0]
118
+ if LIMIT:
119
+ tr, te = tr.head(LIMIT), te.head(max(200, LIMIT // 4)); log(f"AQ_LIMIT={LIMIT}: DRY RUN")
120
+ log(f"train {len(tr):,} · test {len(te):,}")
121
+ Dtr, Mtr, Wtr = build(tr); log("train phases")
122
+ Dte, Mte, Wte = build(te); log("test phases")
123
+ def plain(df):
124
+ yd = pd.to_numeric(df.dob_dad.str[:4], errors="coerce").where(df.dob_dad != "0000-00-00")
125
+ ym = pd.to_numeric(df.dob_mom.str[:4], errors="coerce").where(df.dob_mom != "0000-00-00")
126
+ sy = df.start.str[:4].astype(float)
127
+ return np.column_stack([sy - yd, sy - ym, ym - yd, sy, (df.start.str[5:] == "01-01").astype(float)])
128
+ os.makedirs(OUT, exist_ok=True)
129
+ np.savez_compressed(f"{OUT}/phases.npz", theta_dad_train=Dtr, theta_mom_train=Mtr, theta_wed_train=Wtr,
130
+ theta_dad_test=Dte, theta_mom_test=Mte, theta_wed_test=Wte, bodies=np.array(BODIES, dtype=object),
131
+ y_train=tr[LABEL].to_numpy().astype(np.int8), id_test=te.id.to_numpy() if "id" in te else np.arange(len(te)),
132
+ plain_train=plain(tr), plain_test=plain(te),
133
+ plain_names=np.array(["age_dad_at_start", "age_mom_at_start", "age_gap", "start_year", "start_is_jan1"], dtype=object),
134
+ yr_train=np.column_stack([pd.to_numeric(tr.dob_dad.str[:4], errors="coerce").fillna(0),
135
+ pd.to_numeric(tr.dob_mom.str[:4], errors="coerce").fillna(0)]).astype(np.int16))
136
+ full = np.isfinite(Dtr).all(1) & np.isfinite(Mtr).all(1)
137
+ log(f"wrote {OUT}/phases.npz · {len(BODIES)} bodies · train rows with BOTH natal charts complete: {full.sum():,} · "
138
+ f"wedding sky complete: {np.isfinite(Wtr[:, :10]).all(1).sum():,}")
139
+
140
+
141
+ if __name__ == "__main__":
142
+ main()
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ numpy
2
+ scipy
3
+ kerykeion
4
+ timezonefinder