artaquest commited on
Commit
7f37f18
·
verified ·
1 Parent(s): 79040c9

ArtaModel IV — genderless, even (|Δθ|), every pair in both orders, every long-term relationship; edition III kept below

Browse files
README.md CHANGED
@@ -1,13 +1,98 @@
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
 
1
  ---
2
  license: cc0-1.0
3
  language: [en]
4
+ tags: [astrology, sidereal, jyotisha, relationships, tabular, phase-model, genderless, artaquest]
5
+ datasets: [artaquest-foundation/artamatch-genderless, artaquest-foundation/artamatch-sidereal]
6
  metrics: [roc_auc]
7
  library_name: numpy
8
  ---
9
 
10
+ # ArtaModel IV — genderless
11
+
12
+ **A genderless sidereal phase model of a long-term relationship**, the fourth edition of ArtaModel (Arash
13
+ Ashrafnejad, ArtaQuest Foundation, 2026-08-19: "I want a genderless model from now on"). Two births and birthplaces
14
+ — **in no order** — and the date the relationship began, one probability out: did it last thirty years?
15
+
16
+ ```
17
+ y = | b + Σᵢ aᵢ ·e^{i|θ1ᵢ − θ2ᵢ|} the absolute synastry angle (even under the swap)
18
+ + t1ᵢ·e^{i|θtᵢ − θ1ᵢ|} the wedding sky to partner 1's chart
19
+ + t2ᵢ·e^{i|θtᵢ − θ2ᵢ|} the wedding sky to partner 2's chart
20
+ + n1ᵢ·e^{i θ1ᵢ} partner 1's own natal longitude
21
+ + n2ᵢ·e^{i θ2ᵢ} partner 2'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): births cast at
27
+ **09:00 local time at the birthplace** (nobody's birth time is recorded — the dataset's convention), the start at
28
+ 12:00 UT. **Genderless, three ways:** no sex is read; every phase *difference* enters as its wrapped absolute value
29
+ |Δθ| ∈ [0°, 180°], so each term is an even function of the swap; the training data carries every pair in both
30
+ orders; and the scorer averages the two orders, so the answer is identical whichever way the partners are given.
31
+ **Every long-term relationship** in Wikidata is in the data — marriages of every kind (same-sex included),
32
+ unmarried partnerships, business and sporting partnerships, "significant person" pairs with family excluded.
33
+ **A term exists only when both of its phases exist**: an unknown start day drops the wedding-sky terms, an unknown
34
+ birth drops that partner's terms; a missing phase contributes exactly zero.
35
+
36
+ ## The deployed model, term by term
37
+
38
+ Gradient boosting over **split single-sum fields**: each stage is one field `|bₖ + wₖ·e^{iφ}|²` on **one** phasor,
39
+ chosen greedily as the phasor that best explains the current residual (all 84 phasors of all six terms compete at
40
+ every stage), added to the logit as `stepₖ·(αₖ·u + cₖ)`. Fitted on **all the data — train and test rows with both
41
+ natal charts, 35,894 rows (every pair in both orders)** — for 8 stages (the number the
42
+ train-only fit chose on its inner temporal split). Of the 84 phasors offered it chose **3**:
43
+
44
+ | phasor | body | term | stages | contribution to the logit swing | phase at which the field peaks |
45
+ |---|---|---|---|---|---|
46
+ | `a_uranus` | Uranus | a | 4 | 0.151 | 176° |
47
+ | `t2_neptune` | Neptune | t2 | 2 | 0.099 | 195° |
48
+ | `t1_neptune` | Neptune | t1 | 2 | 0.081 | 331° |
49
+
50
+ Never chosen, at any stage:
51
+ - **a** (a·e^{i|θ1−θ2|}): 13 bodies never chosen — sun, moon, mercury, venus, mars, jupiter, saturn, neptune, pluto, true_node, true_south_node, chiron, mean_lilith
52
+ - **t1** (t1·e^{i|θt−θ1|}): 13 bodies never chosen — sun, moon, mercury, venus, mars, jupiter, saturn, uranus, pluto, true_node, true_south_node, chiron, mean_lilith
53
+ - **t2** (t2·e^{i|θt−θ2|}): 13 bodies never chosen — sun, moon, mercury, venus, mars, jupiter, saturn, uranus, pluto, true_node, true_south_node, chiron, mean_lilith
54
+ - **n1** (n1·e^{iθ1}): 14 bodies never chosen — sun, moon, mercury, venus, mars, jupiter, saturn, uranus, neptune, pluto, true_node, true_south_node, chiron, mean_lilith
55
+ - **n2** (n2·e^{iθ2}): 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 absolute gap between the two births measured by Uranus (4.3°/yr);
59
+ `t1_neptune` and `t2_neptune` are each partner's age at the start measured by Neptune (2.2°/yr) — chosen as a pair,
60
+ as a genderless model should. No natal phase, no wedding-sky phase, no fast body.
61
+
62
+ ## What it scores, honestly
63
+
64
+ | on pairs born after 1900 (temporal hold-out; 7,631 pairs, both orders, symmetrised) | AUC |
65
+ |---|---|
66
+ | ArtaModel IV, fitted on train alone (inner split 0.6326, 8 stages) | **0.6252** (public board 0.6101) |
67
+ | the plain columns — the two ages at the start, the absolute gap, the start year (LightGBM) | 0.6114 (public board 0.6000) |
68
+ | equal-weight rank average of the two | 0.6289 (public board 0.6144) |
69
+ | ArtaModel IV with the two ages held flat (AUC within 3-year age cells) | 0.5659 |
70
+
71
+ What the model reads is the two partners' ages at the start and the absolute gap between their births, through the
72
+ outer planets as clocks — the same finding as every edition before it, now without a sex anywhere in the model. It
73
+ is exactly invariant to the ayanāṁśa, the birth hour and the birthplace; the age-cell-matched row is what is left
74
+ once the ages are held flat. See `ARTAMODEL.md` for the study (editions III and IV).
75
+
76
+ ## Use
77
+
78
+ ```python
79
+ from artamodel_score_iv import predict # needs: numpy, kerykeion, timezonefinder
80
+ r = predict("1936-08-04", 37.943, 23.647, # partner 1: dob, lat, lon
81
+ "1924-05-14", 37.727, 26.909, # partner 2 — the order does not matter
82
+ "1968-06-15") # start date (YYYY-01-01 = year only -> wedding-sky terms dropped)
83
+ r["probability"], r["terms"], r["terms_swapped"]
84
+ ```
85
+
86
+ `artamodel_iv_deployed.json` holds every stage's weights; `artamodel_iv.py` is the fit (with `artamodel.py`,
87
+ `artamodel_deploy.py`, `kerykeion_phases.py`); `artamodel_iv.json` the leaderboard numbers. Data:
88
+ [artaquest-foundation/artamatch-genderless](https://www.kaggle.com/datasets/artaquest-foundation/artamatch-genderless);
89
+ competition: [artamatch-genderless](https://www.kaggle.com/competitions/artamatch-genderless). CC0.
90
+
91
+ ---
92
+
93
+ ## Edition III (superseded 2026-08-19): the gendered model, kept for the record
94
+
95
+ ### ArtaModel (third edition)
96
 
97
  **A sidereal phase model of a marriage**, named by Arash Ashrafnejad (ArtaQuest Foundation, 2026-08-18). Three dates
98
  and two places in — his birth and birthplace, hers, and the wedding date — one probability out: did the marriage
artamodel.py CHANGED
@@ -36,6 +36,9 @@ from coherent_fit import Coherent, auc # noqa
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"]
@@ -51,27 +54,52 @@ def composite(D, M):
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":
@@ -81,7 +109,7 @@ def phase_matrix(D, M, W, all_bodies, bodies, terms, angles_in_natal=False):
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
 
 
36
 
37
  TERMS = ("a", "m", "d", "mn", "dn", "tn")
38
  TERMS8 = TERMS + ("c", "tc")
39
+ # Arash, 2026-08-19: "add the midpoint of natals as another term. also midpoint with their wedding. overall 3 more
40
+ # terms" -> c (midpoint of the two natals), mw (midpoint of mom's natal with the wedding), dw (dad's with the wedding)
41
+ TERMS9 = TERMS + ("c", "mw", "dw")
42
  BODIES14 = ["sun", "moon", "mercury", "venus", "mars", "jupiter", "saturn", "uranus", "neptune", "pluto",
43
  "true_node", "true_south_node", "chiron", "mean_lilith"]
44
  ANGLES = ["ascendant", "medium_coeli"]
 
54
  return (D + diff / 2.0) % 360.0
55
 
56
 
57
+ # FOURTH EDITION — GENDERLESS (operator 2026-08-19): "I want a genderless model from now on ... (a, b, 1) should
58
+ # also mean (b, a, 1) ... for each subtractive term add abs to ensure each term is an even function." The two
59
+ # natal charts are slot 1 and slot 2 with no meaning attached; the files carry every pair in BOTH orders; and
60
+ # every phase DIFFERENCE enters as its wrapped absolute value |Δθ| in [0°, 180°], so a term's value is unchanged
61
+ # when the partners swap (and the wedding-sky terms are even in the same sense: |θt − θ|). Term names for this
62
+ # edition: a (synastry |θ1 − θ2|), t1/t2 (the wedding sky to each partner, |θt − θ|), n1/n2 (each natal phase),
63
+ # tn (the wedding sky's own phase). They map onto the earlier a/m/d/mn/dn/tn computations with `even=True`.
64
+ TERMS_IV = ("a", "t1", "t2", "n1", "n2", "tn")
65
+ _IV_TO_III = {"t1": "d", "t2": "m", "n1": "dn", "n2": "mn"}
66
+
67
+
68
+ def absdiff(x, y):
69
+ """The wrapped absolute phase difference |x − y| in [0, 180] degrees — even in (x, y)."""
70
+ return np.abs((x - y + 180.0) % 360.0 - 180.0)
71
+
72
+
73
+ def phase_matrix(D, M, W, all_bodies, bodies, terms, angles_in_natal=False, even=False):
74
+ """(n, K) phases in degrees (NaN = the term does not exist for that row) and K labels.
75
+ even=True: every subtractive term is the wrapped absolute difference (the genderless edition)."""
76
  col = {b: j for j, b in enumerate(all_bodies)}
77
  P, lab = [], []
78
  use = list(bodies) + (ANGLES if angles_in_natal else [])
79
+ diff = absdiff if even else (lambda x, y: x - y)
80
+ for t0 in terms:
81
+ t = _IV_TO_III.get(t0, t0)
82
  for b in use:
83
  j = col[b]
84
  if t == "a":
85
+ P.append(diff(M[:, j], D[:, j]))
86
  elif t == "c":
87
  P.append(composite(D[:, j], M[:, j]))
88
  elif t == "tc":
89
  if b in ANGLES: continue
90
+ P.append(diff(W[:, j], composite(D[:, j], M[:, j])))
91
+ elif t == "mw":
92
+ if b in ANGLES: continue
93
+ P.append(composite(M[:, j], W[:, j])) # midpoint of mom's natal and the wedding sky
94
+ elif t == "dw":
95
+ if b in ANGLES: continue
96
+ P.append(composite(D[:, j], W[:, j])) # midpoint of dad's natal and the wedding sky
97
  elif t == "m":
98
  if b in ANGLES: continue
99
+ P.append(diff(W[:, j], M[:, j]))
100
  elif t == "d":
101
  if b in ANGLES: continue
102
+ P.append(diff(W[:, j], D[:, j]))
103
  elif t == "mn":
104
  P.append(M[:, j])
105
  elif t == "dn":
 
109
  P.append(W[:, j])
110
  else:
111
  raise ValueError(t)
112
+ lab.append(f"{t0}_{b}")
113
  return (np.column_stack(P) if P else np.zeros((len(D), 0))), lab
114
 
115
 
artamodel_iv.json ADDED
@@ -0,0 +1,303 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "edition": "IV",
3
+ "n_train_rows": 181596,
4
+ "n_test_rows": 15262,
5
+ "n_test_pairs": 7631,
6
+ "n_full_chart_train_rows": 20632,
7
+ "plain": {
8
+ "held": 0.6113819044977837,
9
+ "age_cell_matched": 0.523580650656717
10
+ },
11
+ "artamodel": {
12
+ "held": 0.6251903718901581,
13
+ "held_raw": 0.6252101256366446,
14
+ "age_cell_matched": 0.565921651972106,
15
+ "inner": 0.632624037191737,
16
+ "stages": 8,
17
+ "phasors_used": [
18
+ "a_uranus",
19
+ "t1_neptune",
20
+ "t2_pluto",
21
+ "t2_neptune"
22
+ ],
23
+ "pair_asymmetry": 0.006218895572088684
24
+ },
25
+ "ensemble": {
26
+ "held": 0.6288835478263907,
27
+ "age_cell_matched": 0.5594745705436661
28
+ },
29
+ "terms": {
30
+ "a": "|\u03b81 \u2212 \u03b82|: the absolute synastry angle between the two natal charts (even under swap)",
31
+ "t1": "|\u03b8t \u2212 \u03b81|: the wedding sky to partner 1's natal longitude",
32
+ "t2": "|\u03b8t \u2212 \u03b82|: the wedding sky to partner 2's natal longitude",
33
+ "n1": "partner 1's own natal longitude",
34
+ "n2": "partner 2's own natal longitude",
35
+ "tn": "the wedding-day longitude itself"
36
+ },
37
+ "labels": [
38
+ "a_sun",
39
+ "a_moon",
40
+ "a_mercury",
41
+ "a_venus",
42
+ "a_mars",
43
+ "a_jupiter",
44
+ "a_saturn",
45
+ "a_uranus",
46
+ "a_neptune",
47
+ "a_pluto",
48
+ "a_true_node",
49
+ "a_true_south_node",
50
+ "a_chiron",
51
+ "a_mean_lilith",
52
+ "t1_sun",
53
+ "t1_moon",
54
+ "t1_mercury",
55
+ "t1_venus",
56
+ "t1_mars",
57
+ "t1_jupiter",
58
+ "t1_saturn",
59
+ "t1_uranus",
60
+ "t1_neptune",
61
+ "t1_pluto",
62
+ "t1_true_node",
63
+ "t1_true_south_node",
64
+ "t1_chiron",
65
+ "t1_mean_lilith",
66
+ "t2_sun",
67
+ "t2_moon",
68
+ "t2_mercury",
69
+ "t2_venus",
70
+ "t2_mars",
71
+ "t2_jupiter",
72
+ "t2_saturn",
73
+ "t2_uranus",
74
+ "t2_neptune",
75
+ "t2_pluto",
76
+ "t2_true_node",
77
+ "t2_true_south_node",
78
+ "t2_chiron",
79
+ "t2_mean_lilith",
80
+ "n1_sun",
81
+ "n1_moon",
82
+ "n1_mercury",
83
+ "n1_venus",
84
+ "n1_mars",
85
+ "n1_jupiter",
86
+ "n1_saturn",
87
+ "n1_uranus",
88
+ "n1_neptune",
89
+ "n1_pluto",
90
+ "n1_true_node",
91
+ "n1_true_south_node",
92
+ "n1_chiron",
93
+ "n1_mean_lilith",
94
+ "n2_sun",
95
+ "n2_moon",
96
+ "n2_mercury",
97
+ "n2_venus",
98
+ "n2_mars",
99
+ "n2_jupiter",
100
+ "n2_saturn",
101
+ "n2_uranus",
102
+ "n2_neptune",
103
+ "n2_pluto",
104
+ "n2_true_node",
105
+ "n2_true_south_node",
106
+ "n2_chiron",
107
+ "n2_mean_lilith",
108
+ "tn_sun",
109
+ "tn_moon",
110
+ "tn_mercury",
111
+ "tn_venus",
112
+ "tn_mars",
113
+ "tn_jupiter",
114
+ "tn_saturn",
115
+ "tn_uranus",
116
+ "tn_neptune",
117
+ "tn_pluto",
118
+ "tn_true_node",
119
+ "tn_true_south_node",
120
+ "tn_chiron",
121
+ "tn_mean_lilith"
122
+ ],
123
+ "leaderboard": {
124
+ "F0": -0.32983794552870865,
125
+ "stages": [
126
+ {
127
+ "stage": 1,
128
+ "phasor": 7,
129
+ "step": 0.4,
130
+ "alpha": -0.3025812663117348,
131
+ "c": 0.16726821685620108,
132
+ "w_re": -0.21057191284516086,
133
+ "w_im": -0.425876200333399,
134
+ "b_re": 0.6107863105989085,
135
+ "b_im": 0.5605056821561912
136
+ },
137
+ {
138
+ "stage": 2,
139
+ "phasor": 36,
140
+ "step": 0.4,
141
+ "alpha": 0.519453130834745,
142
+ "c": -0.4099180846986107,
143
+ "w_re": -0.22061897085028806,
144
+ "w_im": -0.24300062023722413,
145
+ "b_re": -0.7282549974830534,
146
+ "b_im": -0.3886675397665921
147
+ },
148
+ {
149
+ "stage": 3,
150
+ "phasor": 22,
151
+ "step": 0.4,
152
+ "alpha": 0.48893778356724893,
153
+ "c": -0.30557951847828757,
154
+ "w_re": 0.2760151368679704,
155
+ "w_im": 0.19036050251246772,
156
+ "b_re": 0.710970393549423,
157
+ "b_im": 0.1646654555251957
158
+ },
159
+ {
160
+ "stage": 4,
161
+ "phasor": 7,
162
+ "step": 0.4,
163
+ "alpha": 0.42082875033094663,
164
+ "c": -0.2170703928424133,
165
+ "w_re": -0.4347436393823073,
166
+ "w_im": -0.13803958644238642,
167
+ "b_re": -0.4352382943096409,
168
+ "b_im": 0.17857939138405746
169
+ },
170
+ {
171
+ "stage": 5,
172
+ "phasor": 22,
173
+ "step": 0.4,
174
+ "alpha": 0.36385820296400506,
175
+ "c": -0.2863029007149194,
176
+ "w_re": 0.37205652641101605,
177
+ "w_im": 0.0036864218033502306,
178
+ "b_re": 0.7643472890323912,
179
+ "b_im": -0.292744659830657
180
+ },
181
+ {
182
+ "stage": 6,
183
+ "phasor": 7,
184
+ "step": 0.4,
185
+ "alpha": 0.22575105498589784,
186
+ "c": -0.23018974010738902,
187
+ "w_re": -0.45212517836925703,
188
+ "w_im": 0.11202375136041444,
189
+ "b_re": -0.5403941985609879,
190
+ "b_im": 0.4898483443114326
191
+ },
192
+ {
193
+ "stage": 7,
194
+ "phasor": 37,
195
+ "step": 0.4,
196
+ "alpha": 0.6364948915292409,
197
+ "c": -0.40972401951564086,
198
+ "w_re": -0.3412319535146097,
199
+ "w_im": 0.2234795041673507,
200
+ "b_re": 0.12619931698751752,
201
+ "b_im": 0.7645164673177844
202
+ },
203
+ {
204
+ "stage": 8,
205
+ "phasor": 22,
206
+ "step": 0.4,
207
+ "alpha": 0.39102210850368074,
208
+ "c": -0.10963081383635781,
209
+ "w_re": -0.373384819083809,
210
+ "w_im": 0.14228082101266815,
211
+ "b_re": -0.3073528966491033,
212
+ "b_im": 0.28233056845889576
213
+ }
214
+ ],
215
+ "inner_auc": 0.632624037191737,
216
+ "labels": [
217
+ "a_sun",
218
+ "a_moon",
219
+ "a_mercury",
220
+ "a_venus",
221
+ "a_mars",
222
+ "a_jupiter",
223
+ "a_saturn",
224
+ "a_uranus",
225
+ "a_neptune",
226
+ "a_pluto",
227
+ "a_true_node",
228
+ "a_true_south_node",
229
+ "a_chiron",
230
+ "a_mean_lilith",
231
+ "t1_sun",
232
+ "t1_moon",
233
+ "t1_mercury",
234
+ "t1_venus",
235
+ "t1_mars",
236
+ "t1_jupiter",
237
+ "t1_saturn",
238
+ "t1_uranus",
239
+ "t1_neptune",
240
+ "t1_pluto",
241
+ "t1_true_node",
242
+ "t1_true_south_node",
243
+ "t1_chiron",
244
+ "t1_mean_lilith",
245
+ "t2_sun",
246
+ "t2_moon",
247
+ "t2_mercury",
248
+ "t2_venus",
249
+ "t2_mars",
250
+ "t2_jupiter",
251
+ "t2_saturn",
252
+ "t2_uranus",
253
+ "t2_neptune",
254
+ "t2_pluto",
255
+ "t2_true_node",
256
+ "t2_true_south_node",
257
+ "t2_chiron",
258
+ "t2_mean_lilith",
259
+ "n1_sun",
260
+ "n1_moon",
261
+ "n1_mercury",
262
+ "n1_venus",
263
+ "n1_mars",
264
+ "n1_jupiter",
265
+ "n1_saturn",
266
+ "n1_uranus",
267
+ "n1_neptune",
268
+ "n1_pluto",
269
+ "n1_true_node",
270
+ "n1_true_south_node",
271
+ "n1_chiron",
272
+ "n1_mean_lilith",
273
+ "n2_sun",
274
+ "n2_moon",
275
+ "n2_mercury",
276
+ "n2_venus",
277
+ "n2_mars",
278
+ "n2_jupiter",
279
+ "n2_saturn",
280
+ "n2_uranus",
281
+ "n2_neptune",
282
+ "n2_pluto",
283
+ "n2_true_node",
284
+ "n2_true_south_node",
285
+ "n2_chiron",
286
+ "n2_mean_lilith",
287
+ "tn_sun",
288
+ "tn_moon",
289
+ "tn_mercury",
290
+ "tn_venus",
291
+ "tn_mars",
292
+ "tn_jupiter",
293
+ "tn_saturn",
294
+ "tn_uranus",
295
+ "tn_neptune",
296
+ "tn_pluto",
297
+ "tn_true_node",
298
+ "tn_true_south_node",
299
+ "tn_chiron",
300
+ "tn_mean_lilith"
301
+ ]
302
+ }
303
+ }
artamodel_iv.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ artamodel_iv.py — ArtaModel, FOURTH EDITION: genderless and even.
3
+
4
+ Operator 2026-08-19: "I want a genderless model from now on. so duplicate all the train and test data. (a, b, 1)
5
+ should also mean (b, a, 1) and add any longterm relationship to the dataset (including gay marriages and business
6
+ partnerships). also for each subtractive terms add abs to ensure each term is an even function. then start over the
7
+ competition."
8
+
9
+ y = | b + Σᵢ aᵢ·e^{i|θ1ᵢ − θ2ᵢ|} + t1ᵢ·e^{i|θtᵢ − θ1ᵢ|} + t2ᵢ·e^{i|θtᵢ − θ2ᵢ|} + n1ᵢ·e^{iθ1ᵢ} + n2ᵢ·e^{iθ2ᵢ} + tnᵢ·e^{iθtᵢ} |²
10
+
11
+ θ1, θ2 = the two partners' sidereal (Lahiri) longitudes at 09:00 local at the birthplace, in the order the row
12
+ gives them (the files carry every pair in both orders, so the model is symmetric by the data); θt = the wedding
13
+ sky at 12:00 UT; every difference is the wrapped absolute value in [0°, 180°], so each term is an even function of
14
+ the swap; the presence rule is unchanged (a term exists only when both its phases exist).
15
+
16
+ Produces, from AQ_PHASES (kerykeion_phases.py on the edition-IV files):
17
+ submission_plain_iv.csv LightGBM on the two ages at the start, |gap|, start year -- the bar
18
+ submission_artamodel_iv.csv the 6-term boosted-over-split construction (greedy, train-only fit)
19
+ submission_ensemble_iv.csv equal-weight rank average of the two
20
+ artamodel_iv.json every number the pages need (held out, age-cell-matched, pair-symmetry check)
21
+ artamodel_iv_deployed.json the same construction fitted on train + test, term by term
22
+ Every submission is SYMMETRISED: the two rows of a pair (p<n>a, p<n>b) receive the mean of their two scores.
23
+ Usage: AQ_PHASES=/tmp/aq4feat/phases.npz AQ_SOL=/tmp/aq4comp/solution.csv AQ_OUT=/tmp/aq4sub python artamodel_iv.py
24
+ """
25
+ import json
26
+ import os
27
+ import sys
28
+ import time
29
+
30
+ import numpy as np
31
+ import pandas as pd
32
+ from scipy.stats import rankdata
33
+
34
+ HERE = os.path.dirname(os.path.abspath(__file__)); sys.path.insert(0, HERE); sys.path.insert(0, os.path.join(HERE, "..", "coherent"))
35
+ from artamodel import BODIES14, TERMS_IV, auc, phase_matrix # noqa: E402
36
+ from artamodel_deploy import boost_recorded, score, BODY_TEXT # noqa: E402
37
+ from artamodel_full_stack import matched # noqa: E402
38
+
39
+ PH = os.environ.get("AQ_PHASES", "/tmp/aq4feat/phases.npz"); SOL = os.environ.get("AQ_SOL", "/tmp/aq4comp/solution.csv")
40
+ OUT = os.environ.get("AQ_OUT", "/tmp/aq4sub"); T0 = time.time()
41
+ log = lambda *a: print(f"[{time.time()-T0:6.0f}s]", *a, flush=True)
42
+ r01 = lambda v: (rankdata(v) - 1) / max(1.0, len(v) - 1)
43
+ TERM_TEXT_IV = {"a": "|θ1 − θ2|: the absolute synastry angle between the two natal charts (even under swap)",
44
+ "t1": "|θt − θ1|: the wedding sky to partner 1's natal longitude", "t2": "|θt − θ2|: the wedding sky to partner 2's natal longitude",
45
+ "n1": "partner 1's own natal longitude", "n2": "partner 2's own natal longitude", "tn": "the wedding-day longitude itself"}
46
+
47
+
48
+ def pair_key(ids):
49
+ return np.array([i[:-1] if (i[-1] in "ab" and i[:-1].lstrip("p").isdigit()) else i for i in ids])
50
+
51
+
52
+ def symmetrise(ids, s):
53
+ """Mean of the two orders of a pair — the genderless prediction; the two rows then carry the same score."""
54
+ k = pair_key(ids); df = pd.DataFrame({"k": k, "s": s}); m = df.groupby("k")["s"].transform("mean").to_numpy()
55
+ return m
56
+
57
+
58
+ def explain_iv(model, labels):
59
+ rows = {}
60
+ for st in model["stages"]:
61
+ lab = labels[st["phasor"]]; term, body = lab.split("_", 1)
62
+ w = complex(st["w_re"], st["w_im"]); b = complex(st["b_re"], st["b_im"])
63
+ r = rows.setdefault(lab, {"phasor": lab, "term": term, "body": BODY_TEXT.get(body, body), "meaning": f"{TERM_TEXT_IV[term]}, for {BODY_TEXT.get(body, body)}",
64
+ "stages": 0, "contribution": 0.0, "phase_deg": None})
65
+ r["stages"] += 1; r["contribution"] += abs(st["step"] * st["alpha"]) * abs(w) ** 2
66
+ r["phase_deg"] = float(np.degrees(np.angle(b) - np.angle(w)) % 360.0)
67
+ used = sorted(rows.values(), key=lambda r: -r["contribution"]); unused = [l for l in labels if l not in rows]
68
+ return used, unused
69
+
70
+
71
+ def main():
72
+ import lightgbm as lgb
73
+ os.makedirs(OUT, exist_ok=True)
74
+ Z = np.load(PH, allow_pickle=True); s1, s2 = list(Z["slots"]); bodies = list(Z["bodies"]); ids = Z["id_test"]; y = Z["y_train"].astype(np.int64)
75
+ Atr, Btr, Wtr = Z[f"theta_{s1}_train"], Z[f"theta_{s2}_train"], Z["theta_wed_train"]; Ate, Bte, Wte = Z[f"theta_{s1}_test"], Z[f"theta_{s2}_test"], Z["theta_wed_test"]
76
+ ptr, pte, pn = Z["plain_train"], Z["plain_test"], list(Z["plain_names"]); later = Z["yr_train"].astype(int).max(1)
77
+ sol = pd.read_csv(SOL).set_index("id"); lab = [c for c in sol.columns if c != "Usage"][0]; yte = sol.loc[ids, lab].to_numpy().astype(int)
78
+ j1 = ptr[:, pn.index("start_is_jan1")] == 1.0; j1e = pte[:, pn.index("start_is_jan1")] == 1.0; Wtr = Wtr.copy(); Wte = Wte.copy(); Wtr[j1] = np.nan; Wte[j1e] = np.nan
79
+ log(f"train {len(y):,} rows · test {len(ids):,} rows ({len(set(pair_key(ids))):,} pairs) · slots {s1}/{s2}")
80
+ # ---- plain: two ages, |gap|, start year (even by construction of |gap| and by the doubled rows) ----
81
+ ia, ib, ig, iy = pn.index(f"age_{s1}_at_start"), pn.index(f"age_{s2}_at_start"), pn.index("age_gap"), pn.index("start_year")
82
+ X = np.column_stack([ptr[:, ia], ptr[:, ib], np.abs(ptr[:, ig]), ptr[:, iy]]); Xe = np.column_stack([pte[:, ia], pte[:, ib], np.abs(pte[:, ig]), pte[:, iy]])
83
+ prm = dict(n_estimators=400, learning_rate=0.03, num_leaves=15, min_child_samples=100, colsample_bytree=0.8, subsample=0.8, subsample_freq=1, reg_lambda=10.0, verbose=-1)
84
+ p_plain = np.zeros(len(Xe))
85
+ for sd in range(3):
86
+ c = lgb.LGBMClassifier(random_state=sd, **prm); c.fit(X, y); p_plain += c.predict_proba(Xe)[:, 1] / 3
87
+ p_plain_s = symmetrise(ids, p_plain)
88
+ ages = pte[:, [ia, ib]]; cell = (np.floor(np.nan_to_num(np.maximum(ages[:, 0], ages[:, 1])) / 3) * 1000 + np.floor(np.nan_to_num(np.minimum(ages[:, 0], ages[:, 1])) / 3)).astype(int)
89
+ log(f" plain: held {auc(yte, p_plain):.4f} (symmetrised {auc(yte, p_plain_s):.4f}) age-cell-matched {matched(yte, p_plain_s, cell):.4f}")
90
+ # ---- ArtaModel IV: even phase matrix, 6 terms x 14 bodies ----
91
+ B = [bodies.index(b) for b in BODIES14]
92
+ P, labels = phase_matrix(Atr, Btr, Wtr, bodies, BODIES14, TERMS_IV, even=True); Pe, _ = phase_matrix(Ate, Bte, Wte, bodies, BODIES14, TERMS_IV, even=True)
93
+ charts = np.isfinite(Atr[:, B]).all(1) & np.isfinite(Btr[:, B]).all(1)
94
+ lat = later[charts]; inner = lat > np.quantile(lat, 0.85)
95
+ lb = boost_recorded(P[charts], y[charts], inner, stages=4 * len(labels), nu=0.1)
96
+ s_lb = score(lb, Pe); s_lb_s = symmetrise(ids, s_lb)
97
+ log(f" ArtaModel IV (leaderboard fit, {int(charts.sum()):,} full-chart rows, {len(lb['stages'])} stages, inner {lb['inner_auc']:.4f}): "
98
+ f"held {auc(yte, s_lb):.4f} (symmetrised {auc(yte, s_lb_s):.4f}) age-cell-matched {matched(yte, s_lb_s, cell):.4f}")
99
+ # symmetry check: how far apart are the two orders' raw scores? (0 = perfectly even model)
100
+ k = pair_key(ids); df = pd.DataFrame({"k": k, "s": r01(s_lb)}); spread = df.groupby("k")["s"].agg(lambda v: abs(v.max() - v.min())).mean()
101
+ log(f" raw pair asymmetry of the ArtaModel ranks (mean |rank_a − rank_b|): {spread:.4f}")
102
+ p_ens = 0.5 * r01(p_plain_s) + 0.5 * r01(s_lb_s)
103
+ log(f" ENSEMBLE plain + ArtaModel IV (equal-weight rank average): held {auc(yte, p_ens):.4f} age-cell-matched {matched(yte, p_ens, cell):.4f}")
104
+ for nm, v in (("plain_iv", p_plain_s), ("artamodel_iv", s_lb_s), ("ensemble_iv", p_ens)):
105
+ pd.DataFrame({"id": ids, lab: r01(v)}).to_csv(os.path.join(OUT, f"submission_{nm}.csv"), index=False)
106
+ used, unused = explain_iv(lb, labels)
107
+ # ---- deployed: train + test full-chart rows, the number of stages the leaderboard fit chose ----
108
+ charts_te = np.isfinite(Ate[:, B]).all(1) & np.isfinite(Bte[:, B]).all(1)
109
+ Pall = np.vstack([P[charts], Pe[charts_te]]); yall = np.concatenate([y[charts], yte[charts_te]])
110
+ dep = boost_recorded(Pall, yall, np.zeros(len(yall), bool), stages=len(lb["stages"]), nu=0.1)
111
+ dused, dunused = explain_iv(dep, labels)
112
+ dep.update({"edition": "IV — genderless, even", "labels": labels, "terms": list(TERMS_IV), "bodies": BODIES14, "fitted_on": "train + test rows with both natal charts (every pair in both orders)",
113
+ "n_rows": int(len(yall)), "leaderboard_estimate": {"inner_auc": lb["inner_auc"], "held_out_auc": float(auc(yte, s_lb_s)), "note": "the same construction fitted on train alone, symmetrised over the pair"},
114
+ "phase_convention": {"zodiac": "sidereal, Lahiri", "engine": "Kerykeion 5.12.9 (Swiss Ephemeris)", "birth_time": "09:00 local at the birthplace", "wedding_time": "12:00 UT",
115
+ "even": "every phase difference is the wrapped absolute value |Δθ| in [0°, 180°]", "order": "slot 1 / slot 2 carry no meaning; score both orders and average",
116
+ "presence_rule": "a term exists only when both of its phases exist; a missing phase contributes zero"},
117
+ "explanation": {"used": dused, "unused": dunused}})
118
+ json.dump(dep, open(os.path.join(OUT, "artamodel_iv_deployed.json"), "w"), indent=1)
119
+ R = {"edition": "IV", "n_train_rows": int(len(y)), "n_test_rows": int(len(ids)), "n_test_pairs": int(len(set(k))), "n_full_chart_train_rows": int(charts.sum()),
120
+ "plain": {"held": float(auc(yte, p_plain_s)), "age_cell_matched": float(matched(yte, p_plain_s, cell))},
121
+ "artamodel": {"held": float(auc(yte, s_lb_s)), "held_raw": float(auc(yte, s_lb)), "age_cell_matched": float(matched(yte, s_lb_s, cell)), "inner": lb["inner_auc"], "stages": len(lb["stages"]),
122
+ "phasors_used": [u["phasor"] for u in used], "pair_asymmetry": float(spread)},
123
+ "ensemble": {"held": float(auc(yte, p_ens)), "age_cell_matched": float(matched(yte, p_ens, cell))},
124
+ "terms": {t: TERM_TEXT_IV[t] for t in TERMS_IV}, "labels": labels, "leaderboard": {**lb, "labels": labels}}
125
+ json.dump(R, open(os.path.join(OUT, "artamodel_iv.json"), "w"), indent=1)
126
+ log(f" deployed fit: {len(yall):,} rows · {len(dep['stages'])} stages · phasors used: {', '.join(u['phasor'] for u in dused)}")
127
+ log(f"wrote {OUT}/submission_{{plain,artamodel,ensemble}}_iv.csv, artamodel_iv.json, artamodel_iv_deployed.json")
128
+
129
+
130
+ if __name__ == "__main__":
131
+ main()
artamodel_iv_deployed.json ADDED
@@ -0,0 +1,335 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "F0": -0.32088433382887727,
3
+ "stages": [
4
+ {
5
+ "stage": 1,
6
+ "phasor": 7,
7
+ "step": 0.4,
8
+ "alpha": -0.3376948209619891,
9
+ "c": 0.1874793060543171,
10
+ "w_re": -0.24321451372901814,
11
+ "w_im": -0.4426184203580808,
12
+ "b_re": 0.6339725314962403,
13
+ "b_im": 0.6026368179437613
14
+ },
15
+ {
16
+ "stage": 2,
17
+ "phasor": 7,
18
+ "step": 0.4,
19
+ "alpha": -0.44634092077748555,
20
+ "c": 0.1116560749961018,
21
+ "w_re": 0.46754000603237206,
22
+ "w_im": 0.2560313440563457,
23
+ "b_re": -0.5437361440779536,
24
+ "b_im": -0.15134405303585938
25
+ },
26
+ {
27
+ "stage": 3,
28
+ "phasor": 22,
29
+ "step": 0.4,
30
+ "alpha": 0.4772890407464341,
31
+ "c": -0.24435517906829834,
32
+ "w_re": 0.49816339813880034,
33
+ "w_im": -0.06628727704385114,
34
+ "b_re": 0.4920847669878105,
35
+ "b_im": -0.4043385699859723
36
+ },
37
+ {
38
+ "stage": 4,
39
+ "phasor": 36,
40
+ "step": 0.4,
41
+ "alpha": 0.4121201205056047,
42
+ "c": -0.22911907658681685,
43
+ "w_re": -0.4990958449581369,
44
+ "w_im": 0.04409056714697404,
45
+ "b_re": -0.552262102086733,
46
+ "b_im": 0.38921839858489915
47
+ },
48
+ {
49
+ "stage": 5,
50
+ "phasor": 7,
51
+ "step": 0.4,
52
+ "alpha": 0.2891143341130896,
53
+ "c": -0.23242638188558676,
54
+ "w_re": 0.29176287067569107,
55
+ "w_im": 0.4353249731528996,
56
+ "b_re": 0.5499183154598283,
57
+ "b_im": 0.17856648674940595
58
+ },
59
+ {
60
+ "stage": 6,
61
+ "phasor": 36,
62
+ "step": 0.4,
63
+ "alpha": -0.30350135615690277,
64
+ "c": 0.1719921813087553,
65
+ "w_re": 0.2898425417803235,
66
+ "w_im": -0.6250770077916671,
67
+ "b_re": -0.5147192423447787,
68
+ "b_im": 0.6150611274518173
69
+ },
70
+ {
71
+ "stage": 7,
72
+ "phasor": 22,
73
+ "step": 0.4,
74
+ "alpha": 0.35284123130265743,
75
+ "c": -0.20570391043323596,
76
+ "w_re": 0.08529216192335105,
77
+ "w_im": 0.4735197468892639,
78
+ "b_re": 0.44026506888173583,
79
+ "b_im": 0.5399124753716616
80
+ },
81
+ {
82
+ "stage": 8,
83
+ "phasor": 7,
84
+ "step": 0.4,
85
+ "alpha": -0.41087990743918523,
86
+ "c": 0.07256860068983159,
87
+ "w_re": 0.3823720953371966,
88
+ "w_im": -0.2417660418214181,
89
+ "b_re": -0.44376920363750716,
90
+ "b_im": 0.32639335063421915
91
+ }
92
+ ],
93
+ "inner_auc": null,
94
+ "edition": "IV \u2014 genderless, even",
95
+ "labels": [
96
+ "a_sun",
97
+ "a_moon",
98
+ "a_mercury",
99
+ "a_venus",
100
+ "a_mars",
101
+ "a_jupiter",
102
+ "a_saturn",
103
+ "a_uranus",
104
+ "a_neptune",
105
+ "a_pluto",
106
+ "a_true_node",
107
+ "a_true_south_node",
108
+ "a_chiron",
109
+ "a_mean_lilith",
110
+ "t1_sun",
111
+ "t1_moon",
112
+ "t1_mercury",
113
+ "t1_venus",
114
+ "t1_mars",
115
+ "t1_jupiter",
116
+ "t1_saturn",
117
+ "t1_uranus",
118
+ "t1_neptune",
119
+ "t1_pluto",
120
+ "t1_true_node",
121
+ "t1_true_south_node",
122
+ "t1_chiron",
123
+ "t1_mean_lilith",
124
+ "t2_sun",
125
+ "t2_moon",
126
+ "t2_mercury",
127
+ "t2_venus",
128
+ "t2_mars",
129
+ "t2_jupiter",
130
+ "t2_saturn",
131
+ "t2_uranus",
132
+ "t2_neptune",
133
+ "t2_pluto",
134
+ "t2_true_node",
135
+ "t2_true_south_node",
136
+ "t2_chiron",
137
+ "t2_mean_lilith",
138
+ "n1_sun",
139
+ "n1_moon",
140
+ "n1_mercury",
141
+ "n1_venus",
142
+ "n1_mars",
143
+ "n1_jupiter",
144
+ "n1_saturn",
145
+ "n1_uranus",
146
+ "n1_neptune",
147
+ "n1_pluto",
148
+ "n1_true_node",
149
+ "n1_true_south_node",
150
+ "n1_chiron",
151
+ "n1_mean_lilith",
152
+ "n2_sun",
153
+ "n2_moon",
154
+ "n2_mercury",
155
+ "n2_venus",
156
+ "n2_mars",
157
+ "n2_jupiter",
158
+ "n2_saturn",
159
+ "n2_uranus",
160
+ "n2_neptune",
161
+ "n2_pluto",
162
+ "n2_true_node",
163
+ "n2_true_south_node",
164
+ "n2_chiron",
165
+ "n2_mean_lilith",
166
+ "tn_sun",
167
+ "tn_moon",
168
+ "tn_mercury",
169
+ "tn_venus",
170
+ "tn_mars",
171
+ "tn_jupiter",
172
+ "tn_saturn",
173
+ "tn_uranus",
174
+ "tn_neptune",
175
+ "tn_pluto",
176
+ "tn_true_node",
177
+ "tn_true_south_node",
178
+ "tn_chiron",
179
+ "tn_mean_lilith"
180
+ ],
181
+ "terms": [
182
+ "a",
183
+ "t1",
184
+ "t2",
185
+ "n1",
186
+ "n2",
187
+ "tn"
188
+ ],
189
+ "bodies": [
190
+ "sun",
191
+ "moon",
192
+ "mercury",
193
+ "venus",
194
+ "mars",
195
+ "jupiter",
196
+ "saturn",
197
+ "uranus",
198
+ "neptune",
199
+ "pluto",
200
+ "true_node",
201
+ "true_south_node",
202
+ "chiron",
203
+ "mean_lilith"
204
+ ],
205
+ "fitted_on": "train + test rows with both natal charts (every pair in both orders)",
206
+ "n_rows": 35894,
207
+ "leaderboard_estimate": {
208
+ "inner_auc": 0.632624037191737,
209
+ "held_out_auc": 0.6251903718901581,
210
+ "note": "the same construction fitted on train alone, symmetrised over the pair"
211
+ },
212
+ "phase_convention": {
213
+ "zodiac": "sidereal, Lahiri",
214
+ "engine": "Kerykeion 5.12.9 (Swiss Ephemeris)",
215
+ "birth_time": "09:00 local at the birthplace",
216
+ "wedding_time": "12:00 UT",
217
+ "even": "every phase difference is the wrapped absolute value |\u0394\u03b8| in [0\u00b0, 180\u00b0]",
218
+ "order": "slot 1 / slot 2 carry no meaning; score both orders and average",
219
+ "presence_rule": "a term exists only when both of its phases exist; a missing phase contributes zero"
220
+ },
221
+ "explanation": {
222
+ "used": [
223
+ {
224
+ "phasor": "a_uranus",
225
+ "term": "a",
226
+ "body": "Uranus",
227
+ "meaning": "|\u03b81 \u2212 \u03b82|: the absolute synastry angle between the two natal charts (even under swap), for Uranus",
228
+ "stages": 4,
229
+ "contribution": 0.15058023776611307,
230
+ "phase_deg": 175.96975487087076
231
+ },
232
+ {
233
+ "phasor": "t2_neptune",
234
+ "term": "t2",
235
+ "body": "Neptune",
236
+ "meaning": "|\u03b8t \u2212 \u03b82|: the wedding sky to partner 2's natal longitude, for Neptune",
237
+ "stages": 2,
238
+ "contribution": 0.09901603485407229,
239
+ "phase_deg": 195.04784294272702
240
+ },
241
+ {
242
+ "phasor": "t1_neptune",
243
+ "term": "t1",
244
+ "body": "Neptune",
245
+ "meaning": "|\u03b8t \u2212 \u03b81|: the wedding sky to partner 1's natal longitude, for Neptune",
246
+ "stages": 2,
247
+ "contribution": 0.08089028752347999,
248
+ "phase_deg": 331.01573734961244
249
+ }
250
+ ],
251
+ "unused": [
252
+ "a_sun",
253
+ "a_moon",
254
+ "a_mercury",
255
+ "a_venus",
256
+ "a_mars",
257
+ "a_jupiter",
258
+ "a_saturn",
259
+ "a_neptune",
260
+ "a_pluto",
261
+ "a_true_node",
262
+ "a_true_south_node",
263
+ "a_chiron",
264
+ "a_mean_lilith",
265
+ "t1_sun",
266
+ "t1_moon",
267
+ "t1_mercury",
268
+ "t1_venus",
269
+ "t1_mars",
270
+ "t1_jupiter",
271
+ "t1_saturn",
272
+ "t1_uranus",
273
+ "t1_pluto",
274
+ "t1_true_node",
275
+ "t1_true_south_node",
276
+ "t1_chiron",
277
+ "t1_mean_lilith",
278
+ "t2_sun",
279
+ "t2_moon",
280
+ "t2_mercury",
281
+ "t2_venus",
282
+ "t2_mars",
283
+ "t2_jupiter",
284
+ "t2_saturn",
285
+ "t2_uranus",
286
+ "t2_pluto",
287
+ "t2_true_node",
288
+ "t2_true_south_node",
289
+ "t2_chiron",
290
+ "t2_mean_lilith",
291
+ "n1_sun",
292
+ "n1_moon",
293
+ "n1_mercury",
294
+ "n1_venus",
295
+ "n1_mars",
296
+ "n1_jupiter",
297
+ "n1_saturn",
298
+ "n1_uranus",
299
+ "n1_neptune",
300
+ "n1_pluto",
301
+ "n1_true_node",
302
+ "n1_true_south_node",
303
+ "n1_chiron",
304
+ "n1_mean_lilith",
305
+ "n2_sun",
306
+ "n2_moon",
307
+ "n2_mercury",
308
+ "n2_venus",
309
+ "n2_mars",
310
+ "n2_jupiter",
311
+ "n2_saturn",
312
+ "n2_uranus",
313
+ "n2_neptune",
314
+ "n2_pluto",
315
+ "n2_true_node",
316
+ "n2_true_south_node",
317
+ "n2_chiron",
318
+ "n2_mean_lilith",
319
+ "tn_sun",
320
+ "tn_moon",
321
+ "tn_mercury",
322
+ "tn_venus",
323
+ "tn_mars",
324
+ "tn_jupiter",
325
+ "tn_saturn",
326
+ "tn_uranus",
327
+ "tn_neptune",
328
+ "tn_pluto",
329
+ "tn_true_node",
330
+ "tn_true_south_node",
331
+ "tn_chiron",
332
+ "tn_mean_lilith"
333
+ ]
334
+ }
335
+ }
artamodel_score_iv.py ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ artamodel_score_iv.py — score a pair with the deployed ArtaModel IV (genderless, even), from three dates and two
3
+ places. numpy only for the model; Kerykeion for the phases (pip install kerykeion timezonefinder).
4
+
5
+ from artamodel_score_iv import predict
6
+ p = predict("1936-08-04", 37.943, 23.647, "1924-05-14", 37.727, 26.909, "1968-06-15") # partner 1, partner 2, start
7
+
8
+ The two partners carry no order: the model is scored in BOTH orders and the two logits are averaged, so the
9
+ answer is exactly the same whichever way round you give them (every phase difference already enters as |Δθ|).
10
+ Returns the probability that the relationship lasted thirty years and the term-by-term account of both orders.
11
+ See README.md / ARTAMODEL.md for what the model is and, honestly, what it reads.
12
+ """
13
+ import json
14
+ import os
15
+ import warnings
16
+
17
+ import numpy as np
18
+
19
+ warnings.filterwarnings("ignore")
20
+ HERE = os.path.dirname(os.path.abspath(__file__))
21
+ MODEL = json.load(open(os.path.join(HERE, "artamodel_iv_deployed.json")))
22
+ BODIES = MODEL["bodies"]; TERMS = MODEL["terms"]; LABELS = MODEL["labels"]
23
+ ANGLES = {"ascendant", "medium_coeli"}
24
+ SLOW = {"jupiter", "saturn", "uranus", "neptune", "pluto", "true_node", "true_south_node", "chiron", "mean_lilith"}
25
+ _TF = None
26
+
27
+
28
+ def _tz(lat, lon):
29
+ global _TF
30
+ if _TF is None:
31
+ from timezonefinder import TimezoneFinder
32
+ _TF = TimezoneFinder()
33
+ return _TF.timezone_at(lng=lon, lat=lat) or "UTC"
34
+
35
+
36
+ def _prec(d):
37
+ if not d or d == "0000-00-00":
38
+ return 0
39
+ return 1 if d.endswith("-00-00") else (2 if d.endswith("-00") else 3)
40
+
41
+
42
+ def theta(date, lat=None, lon=None, hour=9, natal=True):
43
+ """Sidereal (Lahiri) longitudes of the model's bodies at 09:00 local (natal) or 12:00 UT (wedding); NaN where
44
+ the date's precision cannot place a body (year-only -> slow bodies only)."""
45
+ from kerykeion import AstrologicalSubject
46
+ out = np.full(len(BODIES), np.nan); p = _prec(date)
47
+ if p == 0 or (natal and (lat is None or lon is None)):
48
+ return out
49
+ y, m, d = int(date[:4]), max(1, int(date[5:7])), max(1, int(date[8:10]))
50
+ if natal:
51
+ s = AstrologicalSubject("x", y, m, d, hour, 0, lng=float(lon), lat=float(lat), tz_str=_tz(float(lat), float(lon)),
52
+ city="x", nation="XX", zodiac_type="Sidereal", sidereal_mode="LAHIRI", online=False)
53
+ else:
54
+ s = AstrologicalSubject("w", y, m, d, 12, 0, lng=0.0, lat=51.48, tz_str="UTC", city="G", nation="GB",
55
+ zodiac_type="Sidereal", sidereal_mode="LAHIRI", online=False)
56
+ for j, b in enumerate(BODIES):
57
+ if p == 1 and b not in SLOW:
58
+ continue
59
+ if p == 2 and b not in SLOW and b != "sun":
60
+ continue
61
+ try:
62
+ out[j] = float(getattr(s, b).abs_pos)
63
+ except Exception:
64
+ pass
65
+ return out
66
+
67
+
68
+ def absdiff(x, y):
69
+ """The wrapped absolute phase difference in [0, 180] degrees — even in (x, y)."""
70
+ return np.abs((x - y + 180.0) % 360.0 - 180.0)
71
+
72
+
73
+ def phases(t1, t2, tw):
74
+ """The 84 phases in the model's label order (NaN = the term does not exist). Every difference is |Δθ|."""
75
+ P = np.full(len(LABELS), np.nan)
76
+ col = {b: j for j, b in enumerate(BODIES)}
77
+ for k, lab in enumerate(LABELS):
78
+ t, b = lab.split("_", 1); j = col[b]
79
+ if t == "a": P[k] = absdiff(t1[j], t2[j])
80
+ elif t == "t1": P[k] = absdiff(tw[j], t1[j])
81
+ elif t == "t2": P[k] = absdiff(tw[j], t2[j])
82
+ elif t == "n1": P[k] = t1[j]
83
+ elif t == "n2": P[k] = t2[j]
84
+ elif t == "tn": P[k] = tw[j]
85
+ return P
86
+
87
+
88
+ def logit(P):
89
+ """The deployed model's logit for one phase vector, with the per-stage account."""
90
+ rad = np.pi / 180.0
91
+ C, S = np.nan_to_num(np.cos(P * rad)), np.nan_to_num(np.sin(P * rad))
92
+ F = MODEL["F0"]; account = []
93
+ for st in MODEL["stages"]:
94
+ j = st["phasor"]
95
+ if not np.isfinite(P[j]):
96
+ account.append({"stage": st["stage"], "phasor": LABELS[j], "contribution": 0.0, "note": "term absent for this couple"}); continue
97
+ 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"]
98
+ u = Zr * Zr + Zi * Zi; contrib = st["step"] * (st["alpha"] * u + st["c"])
99
+ F += contrib; account.append({"stage": st["stage"], "phasor": LABELS[j], "phase_deg": float(P[j] % 360), "contribution": float(contrib)})
100
+ return float(F), account
101
+
102
+
103
+ def predict(dob_1, lat_1, lon_1, dob_2, lat_2, lon_2, start):
104
+ wed = start if start[5:] != "01-01" else start[:4] + "-00-00" # a 1 January start is a year-only record
105
+ t1, t2, tw = theta(dob_1, lat_1, lon_1), theta(dob_2, lat_2, lon_2), theta(wed, natal=False)
106
+ F12, acc12 = logit(phases(t1, t2, tw)); F21, acc21 = logit(phases(t2, t1, tw))
107
+ F = 0.5 * (F12 + F21)
108
+ return {"probability": float(1 / (1 + np.exp(-F))), "logit": F, "logit_order_12": F12, "logit_order_21": F21,
109
+ "terms": acc12, "terms_swapped": acc21}
110
+
111
+
112
+ if __name__ == "__main__":
113
+ r = predict("1936-08-04", 37.943, 23.647, "1924-05-14", 37.727, 26.909, "1968-06-15")
114
+ r2 = predict("1924-05-14", 37.727, 26.909, "1936-08-04", 37.943, 23.647, "1968-06-15")
115
+ assert abs(r["probability"] - r2["probability"]) < 1e-12, "the scorer is not symmetric"
116
+ print(f" p(lasted 30 years) = {r['probability']:.3f} logit {r['logit']:+.3f} (swapped: {r2['probability']:.3f} — identical)")
117
+ for t in r["terms"][:8]:
118
+ 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"]))
kerykeion_phases.py CHANGED
@@ -100,8 +100,16 @@ def _work(args):
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)
@@ -112,27 +120,31 @@ def build(df):
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():,}")
 
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 slots(df):
104
+ """FOURTH EDITION (genderless, 2026-08-19): the files carry `dob_a/dob_b`; the earlier editions `dob_dad/dob_mom`.
105
+ The extractor reads whichever it is given and names its outputs by the same slot names."""
106
+ return ("a", "b") if "dob_a" in df.columns else ("dad", "mom")
107
+
108
+
109
  def build(df):
110
+ s1, s2 = slots(df)
111
+ jobs = [(i, getattr(r, f"dob_{s1}"), getattr(r, f"lat_{s1}"), getattr(r, f"lon_{s1}"), getattr(r, f"dob_{s2}"),
112
+ getattr(r, f"lat_{s2}"), getattr(r, f"lon_{s2}"), r.start)
113
  for i, r in enumerate(df.itertuples(index=False))]
114
  with mp.Pool(max(1, mp.cpu_count() - 1)) as pool:
115
  res = pool.map(_work, jobs, chunksize=256)
 
120
 
121
 
122
  def main():
123
+ tr = pd.read_csv(f"{SRC}/train.csv", dtype=str); te = pd.read_csv(f"{SRC}/test.csv", dtype=str)
124
+ s1, s2 = slots(tr)
125
+ for df in (tr, te):
126
+ for c in (f"lat_{s1}", f"lon_{s1}", f"lat_{s2}", f"lon_{s2}"):
127
+ df[c] = pd.to_numeric(df[c], errors="coerce")
128
+ LABEL = [c for c in tr.columns if c not in {"id", f"dob_{s1}", f"dob_{s2}", f"lat_{s1}", f"lon_{s1}", f"lat_{s2}", f"lon_{s2}", "start"}][0]
129
+ d1, d2 = tr[f"dob_{s1}"], tr[f"dob_{s2}"]
130
  if LIMIT:
131
  tr, te = tr.head(LIMIT), te.head(max(200, LIMIT // 4)); log(f"AQ_LIMIT={LIMIT}: DRY RUN")
132
  log(f"train {len(tr):,} · test {len(te):,}")
133
  Dtr, Mtr, Wtr = build(tr); log("train phases")
134
  Dte, Mte, Wte = build(te); log("test phases")
135
  def plain(df):
136
+ yd = pd.to_numeric(df[f"dob_{s1}"].str[:4], errors="coerce").where(df[f"dob_{s1}"] != "0000-00-00")
137
+ ym = pd.to_numeric(df[f"dob_{s2}"].str[:4], errors="coerce").where(df[f"dob_{s2}"] != "0000-00-00")
138
  sy = df.start.str[:4].astype(float)
139
  return np.column_stack([sy - yd, sy - ym, ym - yd, sy, (df.start.str[5:] == "01-01").astype(float)])
140
  os.makedirs(OUT, exist_ok=True)
141
+ np.savez_compressed(f"{OUT}/phases.npz", **{f"theta_{s1}_train": Dtr, f"theta_{s2}_train": Mtr, "theta_wed_train": Wtr,
142
+ f"theta_{s1}_test": Dte, f"theta_{s2}_test": Mte, "theta_wed_test": Wte}, bodies=np.array(BODIES, dtype=object), slots=np.array([s1, s2], dtype=object),
143
  y_train=tr[LABEL].to_numpy().astype(np.int8), id_test=te.id.to_numpy() if "id" in te else np.arange(len(te)),
144
  plain_train=plain(tr), plain_test=plain(te),
145
+ plain_names=np.array([f"age_{s1}_at_start", f"age_{s2}_at_start", "age_gap", "start_year", "start_is_jan1"], dtype=object),
146
+ yr_train=np.column_stack([pd.to_numeric(d1.str[:4], errors="coerce").fillna(0),
147
+ pd.to_numeric(d2.str[:4], errors="coerce").fillna(0)]).astype(np.int16))
148
  full = np.isfinite(Dtr).all(1) & np.isfinite(Mtr).all(1)
149
  log(f"wrote {OUT}/phases.npz · {len(BODIES)} bodies · train rows with BOTH natal charts complete: {full.sum():,} · "
150
  f"wedding sky complete: {np.isfinite(Wtr[:, :10]).all(1).sum():,}")