morphtree 2.1.0__py3-none-win_amd64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- morphtree/__init__.py +28 -0
- morphtree/__main__.py +3 -0
- morphtree/_experimental/__init__.py +1 -0
- morphtree/_experimental/admission.py +364 -0
- morphtree/_experimental/base.py +215 -0
- morphtree/_experimental/component_cache_v2.py +263 -0
- morphtree/_experimental/evolution_report.py +307 -0
- morphtree/_experimental/execution_v2.py +697 -0
- morphtree/_experimental/facade.py +289 -0
- morphtree/_experimental/integrated.py +748 -0
- morphtree/_experimental/learning.py +418 -0
- morphtree/_experimental/manifest.json +204 -0
- morphtree/_experimental/numeric_fixture.py +21 -0
- morphtree/_experimental/parameter_contract.py +38 -0
- morphtree/_experimental/postfit.py +51 -0
- morphtree/_experimental/preprocessing.py +133 -0
- morphtree/_experimental/processes.py +191 -0
- morphtree/_experimental/producer.py +542 -0
- morphtree/_experimental/product.py +156 -0
- morphtree/_experimental/product_cli.py +43 -0
- morphtree/_experimental/roundtrip_v3.py +311 -0
- morphtree/_experimental/runtime_io.py +65 -0
- morphtree/_experimental/sampling.py +26 -0
- morphtree/_experimental/support.py +281 -0
- morphtree/_experimental/target.py +59 -0
- morphtree/_experimental/training.py +53 -0
- morphtree/_experimental/worker.py +682 -0
- morphtree/_installed.py +17 -0
- morphtree/_native/__init__.py +1 -0
- morphtree/_native/bin/win_amd64/morphtree_kernels.dll +0 -0
- morphtree/_native/buffers.py +82 -0
- morphtree/_native/loader.py +39 -0
- morphtree/_native/manifest.json +29 -0
- morphtree/_native/runtime.py +48 -0
- morphtree/_native/src/histogram.c +61 -0
- morphtree/_native/src/traversal.c +51 -0
- morphtree/api.py +388 -0
- morphtree/api_compat.py +65 -0
- morphtree/backend.py +162 -0
- morphtree/booster.py +252 -0
- morphtree/cli.py +52 -0
- morphtree/config.py +262 -0
- morphtree/control.py +20 -0
- morphtree/data.py +300 -0
- morphtree/diagnostics.py +158 -0
- morphtree/estimators.py +156 -0
- morphtree/examples.py +25 -0
- morphtree/exporting.py +224 -0
- morphtree/legacy_artifacts.py +32 -0
- morphtree/metrics.py +156 -0
- morphtree/model.py +1010 -0
- morphtree/mutations.py +205 -0
- morphtree/native_lab_backend.py +79 -0
- morphtree/numpy_backend.py +180 -0
- morphtree/optuna_tuner.py +154 -0
- morphtree/persistence.py +414 -0
- morphtree/prediction_output.py +35 -0
- morphtree/prepared_bins.py +82 -0
- morphtree/public.py +106 -0
- morphtree/resource_plan.py +170 -0
- morphtree/scalable.py +781 -0
- morphtree/scalable_backend.py +74 -0
- morphtree/scalable_checkpoint.py +129 -0
- morphtree/scalable_data.py +255 -0
- morphtree/scalable_receipts.py +92 -0
- morphtree/scalable_target.py +68 -0
- morphtree/scalable_variants.py +133 -0
- morphtree/sklearn_view.py +79 -0
- morphtree/source_markers.py +25 -0
- morphtree/species.py +47 -0
- morphtree/temporary.py +65 -0
- morphtree/training_fragment.py +130 -0
- morphtree/tree.py +231 -0
- morphtree/tuning.py +150 -0
- morphtree-2.1.0.dist-info/METADATA +212 -0
- morphtree-2.1.0.dist-info/RECORD +79 -0
- morphtree-2.1.0.dist-info/WHEEL +4 -0
- morphtree-2.1.0.dist-info/entry_points.txt +2 -0
- morphtree-2.1.0.dist-info/licenses/LICENSE +21 -0
morphtree/api.py
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
"""Thin installed Python interface; existing learner/admission/loader only.
|
|
2
|
+
|
|
3
|
+
This file is packaged as morphtree.api in the experimental wheel. An owned
|
|
4
|
+
execution budget is supplied internally by the existing runner, never inferred
|
|
5
|
+
from a constructor, a mode, a model file or an ordinary user call.
|
|
6
|
+
"""
|
|
7
|
+
import copy
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import tempfile
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
import uuid
|
|
14
|
+
from .temporary import OwnedTemporaryDirectory
|
|
15
|
+
from contextlib import contextmanager
|
|
16
|
+
from dataclasses import asdict
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from ._experimental.integrated import MorphTree as Integrated
|
|
19
|
+
from ._experimental.admission import AdmissionError, sha
|
|
20
|
+
from ._experimental.execution_v2 import require_session, ACTIVE, Session, PUBLIC_SCOPE
|
|
21
|
+
|
|
22
|
+
_USER_RUN_LOCK=threading.Lock()
|
|
23
|
+
PUBLIC_USER_ENABLED=True # Enabled only by the explicitly versioned product build.
|
|
24
|
+
PUBLIC_USER_RUNTIME='2.1.0'
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _user_identity(package, q):
|
|
28
|
+
"""Self-check installed user distribution; NOT scientific qualification."""
|
|
29
|
+
import base64,csv,hashlib,io,sys
|
|
30
|
+
from ._experimental import admission as A
|
|
31
|
+
package=Path(package).resolve()
|
|
32
|
+
if q.get('usage')!='ORDINARY_USER_NOT_SCIENTIFIC':raise ValueError('Public-user identity domain required')
|
|
33
|
+
manifest=A.read(package/'_experimental/manifest.json',expected_sha256=q['manifest_sha256'])
|
|
34
|
+
if manifest['version']!=q['version'] or q['version']!=PUBLIC_USER_RUNTIME or A.digest(manifest)!=q['build_identity']:
|
|
35
|
+
raise ValueError('User distribution manifest/version differs')
|
|
36
|
+
files={p.relative_to(package).as_posix() for p in package.rglob('*') if p.is_file() and '__pycache__' not in p.parts}
|
|
37
|
+
if files!=set(manifest['files'])|{'_experimental/manifest.json'}:raise ValueError('Unexpected/missing installed user component')
|
|
38
|
+
# One byte snapshot must satisfy BOTH manifest and installed RECORD.
|
|
39
|
+
# No cross-operation cache:every integrity boundary still reopens files.
|
|
40
|
+
checked_content={}
|
|
41
|
+
for n,h in manifest['files'].items():
|
|
42
|
+
data=A.safe_path(package,n).read_bytes();raw_hash=hashlib.sha256(data).digest()
|
|
43
|
+
if raw_hash.hex()!=h:raise ValueError('Altered installed user component')
|
|
44
|
+
checked_content[n]=(raw_hash,len(data))
|
|
45
|
+
data=(package/'_experimental/manifest.json').read_bytes()
|
|
46
|
+
if hashlib.sha256(data).hexdigest()!=q['manifest_sha256']:raise ValueError('User manifest changed during verification')
|
|
47
|
+
checked_content['_experimental/manifest.json']=(hashlib.sha256(data).digest(),len(data))
|
|
48
|
+
infos=list(package.parent.glob('morphtree-*.dist-info'))
|
|
49
|
+
if len(infos)!=1:raise ValueError('Unique distribution metadata required')
|
|
50
|
+
info=infos[0];meta=info/'METADATA';record=info/'RECORD'
|
|
51
|
+
if A.sha(meta)!=q['metadata_sha256'] or A.sha(record)!=q['record_sha256']:raise ValueError('Altered installed metadata/RECORD')
|
|
52
|
+
if 'Name: morphtree\n' not in meta.read_text('utf8') or 'Version: '+q['version']+'\n' not in meta.read_text('utf8'):
|
|
53
|
+
raise ValueError('User metadata name/version mismatch')
|
|
54
|
+
rows={n:(h,size) for n,h,size in csv.reader(io.StringIO(record.read_text('utf8')))}
|
|
55
|
+
for n in list(manifest['files'])+['_experimental/manifest.json']:
|
|
56
|
+
h,size=rows.get('morphtree/'+n,('',0));raw_hash,actual_size=checked_content[n]
|
|
57
|
+
expected='sha256='+base64.urlsafe_b64encode(raw_hash).decode().rstrip('=')
|
|
58
|
+
if h!=expected or int(size)!=actual_size:raise ValueError('Installed user RECORD content differs')
|
|
59
|
+
import morphtree
|
|
60
|
+
if Path(morphtree.__file__).resolve()!=package/'__init__.py':raise ValueError('Shadowed public package')
|
|
61
|
+
for n,m in tuple(sys.modules.items()):
|
|
62
|
+
if n=='morphtree' or n.startswith('morphtree.'):
|
|
63
|
+
f=getattr(m,'__file__',None)
|
|
64
|
+
if f and not Path(f).resolve().is_relative_to(package):raise ValueError('Mixed user/source module origins')
|
|
65
|
+
if q['environment']!=A.environment():raise ValueError('User execution environment changed')
|
|
66
|
+
return {'runtime':'morphtree-user-installed','version':q['version'],'artifact_sha256':q['record_sha256'],
|
|
67
|
+
'artifact_identity_kind':'INSTALLED_RECORD_NOT_ORIGINAL_WHEEL_OR_SCIENTIFIC_QUALIFICATION',
|
|
68
|
+
'build_identity':q['build_identity'],'manifest_sha256':q['manifest_sha256'],'backend':'pure','environment':q['environment']}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _user_qualification(package,root,source,protocol,seconds,user_limits):
|
|
72
|
+
from ._experimental import admission as A
|
|
73
|
+
manifest=package/'_experimental/manifest.json';m=A.read(manifest)
|
|
74
|
+
infos=list(package.parent.glob('morphtree-*.dist-info'))
|
|
75
|
+
if len(infos)!=1:raise ValueError('Unique installed user distribution required')
|
|
76
|
+
return {'schema':'morph030-qualified-installed-v1','usage':'ORDINARY_USER_NOT_SCIENTIFIC',
|
|
77
|
+
'version':m['version'],'backend':'pure','environment':A.environment(),'manifest_sha256':A.sha(manifest),
|
|
78
|
+
'build_identity':A.digest(m),'metadata_sha256':A.sha(infos[0]/'METADATA'),'record_sha256':A.sha(infos[0]/'RECORD'),
|
|
79
|
+
'input_root':str(root),'deadline_utc_epoch':time.time()+seconds,
|
|
80
|
+
'limits':{'fits':128,'trials':user_limits['trials'],'operations':256,'seconds':seconds},'scalar_cap':2048,
|
|
81
|
+
'sources':{A.digest(source):source},'components':[],'component_contracts':{},'protocol':protocol,
|
|
82
|
+
'authorization':{'id':'USER-API:'+uuid.uuid4().hex,'operations':['inspect','load','save','export','fit','trial']},
|
|
83
|
+
'optional_dependency':None}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _user_source(source, expected):
|
|
87
|
+
"""No user-supplied path/role converts this scope into research authority."""
|
|
88
|
+
from ._experimental import admission as A
|
|
89
|
+
if source!=expected or set(source)!={'origin','exposure','sealed','rows','partitions','OOF'}:
|
|
90
|
+
raise ValueError('Exact ordinary user source required')
|
|
91
|
+
if source['exposure']!='USER_SUPPLIED_NOT_SCIENTIFIC' or source['sealed'] or source['OOF']:
|
|
92
|
+
raise ValueError('Controlled/OOF/sealed source is not an ordinary input')
|
|
93
|
+
ids=[r['id'] for r in source['rows']];parts=source['partitions']
|
|
94
|
+
if not source['origin'].startswith('USER_IN_MEMORY:') or len(ids)!=len(set(ids)) or not ids:
|
|
95
|
+
raise ValueError('User source identity/rows required')
|
|
96
|
+
if set(parts)!={'TRAIN','SEARCH','GUARD_FINAL'} or parts['GUARD_FINAL']:
|
|
97
|
+
raise ValueError('User scope never opens GUARD')
|
|
98
|
+
union=parts['TRAIN']+parts['SEARCH']
|
|
99
|
+
if not parts['TRAIN'] or not parts['SEARCH'] or len(union)!=len(set(union)) or set(union)!=set(ids):
|
|
100
|
+
raise ValueError('Exact disjoint internal user split required')
|
|
101
|
+
# Do not silently count duplicated observations as independent validation.
|
|
102
|
+
rows=[A.digest({'X':r['X'],'y':r['y']}) for r in source['rows']]
|
|
103
|
+
if len(rows)!=len(set(rows)):raise ValueError('Duplicate-row groups need a separately qualified splitting policy')
|
|
104
|
+
return A.digest(source)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _new_user_session(estimator, X, y, *, started=None):
|
|
108
|
+
"""Internal ordinary-user scope; never an owner/scientific capability.
|
|
109
|
+
|
|
110
|
+
Input is supplied in memory, not looked up in a research workspace. The
|
|
111
|
+
existing runtime/admission/store/executor are reused. No caller can use a
|
|
112
|
+
failed active scientific context as a request to enter this branch.
|
|
113
|
+
"""
|
|
114
|
+
from ._experimental import admission as A
|
|
115
|
+
from ._experimental.target import detect_target, _split, _NamedRows
|
|
116
|
+
from ._experimental.preprocessing import _m26_rows
|
|
117
|
+
from ._experimental.producer import PIPE_CURRENT
|
|
118
|
+
from ._experimental.runtime_io import configure
|
|
119
|
+
from .config import HyperParams
|
|
120
|
+
started=time.monotonic() if started is None else started
|
|
121
|
+
scope=PUBLIC_SCOPE.get()
|
|
122
|
+
if scope is not None:scope.before_input(estimator)
|
|
123
|
+
if ACTIVE.get() is not None or A.CURRENT.get() is not None or PIPE_CURRENT.get() is not None:
|
|
124
|
+
raise AdmissionError({'status':'REJECTED','reason':'RESEARCH_CONTEXT_NEVER_FALLS_THROUGH_TO_PUBLIC'})
|
|
125
|
+
for obj in (X,y):
|
|
126
|
+
if any(hasattr(obj,k) for k in ('_morphtree_source_role','_morphtree_scientific_source','_morphtree_sealed')):
|
|
127
|
+
raise ValueError('Controlled-source objects require their explicit scientific context')
|
|
128
|
+
estimator._validate()
|
|
129
|
+
if estimator._research_growth:
|
|
130
|
+
raise ValueError('Research operator overrides require a scientific execution context')
|
|
131
|
+
# Dependency availability is checked before source materialization or fits.
|
|
132
|
+
if estimator.tuning=='optuna':
|
|
133
|
+
import importlib.util
|
|
134
|
+
if importlib.util.find_spec('optuna') is None:
|
|
135
|
+
raise ImportError('Final Optuna is a standard dependency; install optuna==5.0.0 or use_optuna=False skips only numerical tuning')
|
|
136
|
+
rows,names=_m26_rows(X);task,targets,classes=detect_target(y,estimator.task)
|
|
137
|
+
if len(rows)!=len(targets):raise ValueError('Aligned X/y required')
|
|
138
|
+
if sum(4*len(v) if isinstance(v,str) else 8 for row in rows for v in row)>2*1024**2:
|
|
139
|
+
raise ValueError('Current ordinary-user input exceeds the2MiB estimated payload safety cap')
|
|
140
|
+
raw_y=list(targets) if task=='regression' else [classes[i] for i in targets]
|
|
141
|
+
if scope is not None:scope.input_rows(rows,raw_y,names,task)
|
|
142
|
+
seed=estimator.random_state
|
|
143
|
+
if seed is None:
|
|
144
|
+
import random
|
|
145
|
+
seed=random.SystemRandom().randrange(2**31)
|
|
146
|
+
ti,si=_split(targets,task,seed)
|
|
147
|
+
if len(ti)>65536:raise ValueError('Current native component path supports at most65536 TRAIN rows')
|
|
148
|
+
from ._experimental.preprocessing import TrainEncoder
|
|
149
|
+
if TrainEncoder().fit([rows[i] for i in ti]).width_>128:
|
|
150
|
+
raise ValueError('Current native component path supports at most128 encoded features')
|
|
151
|
+
source={'origin':'USER_IN_MEMORY:'+A.digest({'X':rows,'y':raw_y,'names':names}),
|
|
152
|
+
'exposure':'USER_SUPPLIED_NOT_SCIENTIFIC','sealed':False,
|
|
153
|
+
'rows':[{'id':'ROW:'+str(i),'X':r,'y':raw_y[i]} for i,r in enumerate(rows)],
|
|
154
|
+
'partitions':{'TRAIN':['ROW:'+str(i) for i in ti],'SEARCH':['ROW:'+str(i) for i in si],'GUARD_FINAL':[]},'OOF':[]}
|
|
155
|
+
seconds=150. if estimator.time_budget=='auto' else float(estimator.time_budget)
|
|
156
|
+
params=asdict(HyperParams(n_estimators=2,max_leaves=8,max_depth=4,max_bins=15,min_samples_leaf=2,row_fraction=1.))
|
|
157
|
+
if estimator.capacity:params.update(estimator.capacity)
|
|
158
|
+
# Original finite learning adapter supports <=8 stages/members and <=4
|
|
159
|
+
# numerical trials. These are resource limits, not scientific grants.
|
|
160
|
+
limits={'calls':128,'scalar_trees':8192,'trials':0 if estimator.tuning=='none' else 2 if estimator.n_trials=='auto' else estimator.n_trials,'artifact_bytes':32*1024**2}
|
|
161
|
+
temporary=OwnedTemporaryDirectory(prefix='morphtree-user-',dir=None if scope is None else scope.calls)
|
|
162
|
+
root=Path(temporary.name).resolve();configure(root)
|
|
163
|
+
if scope is not None:scope.retain_temporary(temporary,root)
|
|
164
|
+
try:
|
|
165
|
+
protocol={'schema':'morphtree-user-call-v1','purpose':'ORDINARY_USER_NOT_SCIENTIFIC',
|
|
166
|
+
'id':uuid.uuid4().hex,'source_sha256':A.digest(source),'task':task,'seed':seed,'limits':limits,'seconds':seconds}
|
|
167
|
+
pp=root/'USER-CALL.json';pp.write_text(json.dumps(protocol,sort_keys=True)+'\n',encoding='utf8')
|
|
168
|
+
reference={'path':pp.name,'sha256':A.sha(pp)}
|
|
169
|
+
q=A.user_qualification(Path(__file__).parent,root,source,reference,seconds,limits)
|
|
170
|
+
preparation=time.monotonic()-started
|
|
171
|
+
if preparation>=seconds:raise TimeoutError('User budget exhausted during input preparation')
|
|
172
|
+
q['deadline_utc_epoch']-=preparation
|
|
173
|
+
qp=root/'USER-ADMISSION.json';qp.write_text(json.dumps(q,sort_keys=True)+'\n',encoding='utf8')
|
|
174
|
+
engine=A.AdmissionEngine(package=Path(__file__).parent,qualification_path=qp,
|
|
175
|
+
qualification_sha256=A.sha(qp),workdir=root)
|
|
176
|
+
plan={'schema':'comp002-v2-session-v1','mode':'PUBLIC_USER','source':source,
|
|
177
|
+
'protocol':reference,'seed':seed,'params':params,'structural':estimator.morphing=='auto',
|
|
178
|
+
'max_requests':128,'seconds':seconds,'user_limits':limits}
|
|
179
|
+
session=Session(engine,root=root,plan=plan)
|
|
180
|
+
session.started=started;session.engine.started=started
|
|
181
|
+
session.event('USER_INPUT_PREPARED',seconds_operational=preparation,source_scope='USER_SUPPLIED_NOT_SCIENTIFIC')
|
|
182
|
+
return temporary,session,_NamedRows(rows,names),raw_y,seed
|
|
183
|
+
except BaseException as primary:
|
|
184
|
+
if scope is None:
|
|
185
|
+
try:temporary.cleanup()
|
|
186
|
+
except BaseException as cleanup_error:BaseException.add_note(primary,'Owned user workspace cleanup also failed: '+type(cleanup_error).__name__)
|
|
187
|
+
raise
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class TrainingNotEnabledError(RuntimeError):
|
|
191
|
+
"""This experimental run has no approved training execution budget."""
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class MorphTree(Integrated):
|
|
195
|
+
"""COMP007 source: initial native exploration then bounded evolution.
|
|
196
|
+
|
|
197
|
+
User code only supplies data and normal estimator options. The qualifying
|
|
198
|
+
host owns execution authorization; this build does not grant unrestricted
|
|
199
|
+
training. Adaptive may abstain; research requires an explicit operator and
|
|
200
|
+
a separately admitted run. Neither choice implies scientific superiority.
|
|
201
|
+
"""
|
|
202
|
+
def __init__(self, *, mode=None, operators=None, verbosity=None,
|
|
203
|
+
use_optuna=None, max_mutations=2, **kwargs):
|
|
204
|
+
if type(max_mutations) is not int or not 0<=max_mutations<=8:
|
|
205
|
+
raise ValueError('max_mutations must be an integer in [0,8]')
|
|
206
|
+
if verbosity is not None:
|
|
207
|
+
if type(verbosity) is not int or verbosity not in (0,1) or 'verbose' in kwargs:
|
|
208
|
+
raise ValueError('Choose implemented verbosity 0/1 or legacy verbose, not both')
|
|
209
|
+
kwargs['verbose']=verbosity
|
|
210
|
+
if use_optuna is not None and type(use_optuna) is not bool:
|
|
211
|
+
raise ValueError('use_optuna must be bool')
|
|
212
|
+
if use_optuna is not None and 'tuning' in kwargs:
|
|
213
|
+
raise ValueError('Choose use_optuna or legacy tuning, not both')
|
|
214
|
+
# No silent optional-dependency skip: the existing explicit optuna path
|
|
215
|
+
# checks the dependency and separate trial admission BEFORE any learning.
|
|
216
|
+
if 'tuning' not in kwargs:
|
|
217
|
+
kwargs['tuning']='none' if use_optuna is False else 'optuna'
|
|
218
|
+
automatic=mode is None and not (set(kwargs)&{'families','morphing'})
|
|
219
|
+
if automatic:mode='adaptive'
|
|
220
|
+
if mode is not None:
|
|
221
|
+
if mode not in ('baseline', 'adaptive', 'research'):
|
|
222
|
+
raise ValueError('mode must be baseline, adaptive or research')
|
|
223
|
+
if set(kwargs) & {'families', 'morphing'}:
|
|
224
|
+
raise ValueError('Choose mode or legacy families/morphing, not both')
|
|
225
|
+
kwargs.update(families='boost' if mode=='baseline' else 'auto',
|
|
226
|
+
morphing='none' if mode=='baseline' else 'auto')
|
|
227
|
+
if operators is not None and (mode!='research' or tuple(operators)!=('growth_policy:depthwise',)):
|
|
228
|
+
raise ValueError('Only explicit research growth_policy:depthwise is available')
|
|
229
|
+
if mode=='research' and operators is None:
|
|
230
|
+
raise ValueError('research requires explicit operators')
|
|
231
|
+
super().__init__(**kwargs)
|
|
232
|
+
if mode=='research': self._research_growth=True
|
|
233
|
+
self._evolving_program=(not self._research_growth and self.families=='auto' and self.morphing=='auto')
|
|
234
|
+
self._max_mutations=max_mutations
|
|
235
|
+
|
|
236
|
+
def fit(self, X, y):
|
|
237
|
+
if ACTIVE.get() is not None:
|
|
238
|
+
# An invalid/expired/STOP scientific context MUST raise here, never
|
|
239
|
+
# retry as ordinary user data. Original owner checks remain active.
|
|
240
|
+
require_session()
|
|
241
|
+
return super().fit(X,y)
|
|
242
|
+
from ._experimental import admission as A
|
|
243
|
+
from ._experimental.producer import PIPE_CURRENT
|
|
244
|
+
from ._experimental.runtime_io import WORKDIR
|
|
245
|
+
if A.CURRENT.get() is not None or PIPE_CURRENT.get() is not None:
|
|
246
|
+
raise AdmissionError({'status':'REJECTED','reason':'ORPHANED_OPERATION_NOT_PUBLIC_DATA'})
|
|
247
|
+
if not PUBLIC_USER_ENABLED:
|
|
248
|
+
raise TrainingNotEnabledError('This installed version requires its explicitly approved execution context')
|
|
249
|
+
if not _USER_RUN_LOCK.acquire(blocking=False):
|
|
250
|
+
raise RuntimeError('Concurrent fit in this process is unsupported; use separate processes')
|
|
251
|
+
work_token=WORKDIR.set(WORKDIR.get());old_temp=tempfile.tempdir
|
|
252
|
+
workspace=None;session=None;old_seed=self.random_state
|
|
253
|
+
before=dict(self.__dict__);closure_attempted=False;started=time.monotonic()
|
|
254
|
+
try:
|
|
255
|
+
workspace,session,xx,yy,seed=_new_user_session(self,X,y,started=started)
|
|
256
|
+
self.random_state=seed
|
|
257
|
+
with session.activate():result=super().fit(xx,yy)
|
|
258
|
+
session._user_integrity()
|
|
259
|
+
closure_attempted=True;session.close_user_run()
|
|
260
|
+
self._public_user_run=True
|
|
261
|
+
self._public_workspace=workspace
|
|
262
|
+
self._public_resource_limits=copy.deepcopy(session.plan['user_limits'])
|
|
263
|
+
self._cost=session.counters()
|
|
264
|
+
return result
|
|
265
|
+
except BaseException as exc:
|
|
266
|
+
attempt=copy.deepcopy(getattr(self,'last_attempt_',{}))
|
|
267
|
+
if attempt==before.get('last_attempt_',{}):
|
|
268
|
+
attempt={'status':'FAIL','phase':'PUBLIC_ADMISSION','physical_fits':getattr(session,'_user_started',0),'trials':getattr(session,'_user_trials',0)}
|
|
269
|
+
if attempt.get('status')=='PASS':attempt.update(status='FAIL',stop_reason='PUBLIC_FINALIZATION_FAILED_RESTORED_PRIOR_OBJECT')
|
|
270
|
+
attempt.update(error=type(exc).__name__,message=str(exc))
|
|
271
|
+
self.__dict__.clear();self.__dict__.update(before);self.last_attempt_=attempt
|
|
272
|
+
if workspace is not None:self._failed_public_workspace=workspace
|
|
273
|
+
raise
|
|
274
|
+
finally:
|
|
275
|
+
self.random_state=old_seed
|
|
276
|
+
try:
|
|
277
|
+
if session is not None and not closure_attempted:
|
|
278
|
+
closure_attempted=True;session.close_user_run()
|
|
279
|
+
except BaseException as exc:
|
|
280
|
+
self.last_attempt_.update(closure_status='UNKNOWN_FAILED',closure_error=type(exc).__name__)
|
|
281
|
+
raise
|
|
282
|
+
finally:
|
|
283
|
+
tempfile.tempdir=old_temp;WORKDIR.reset(work_token)
|
|
284
|
+
_USER_RUN_LOCK.release()
|
|
285
|
+
|
|
286
|
+
@contextmanager
|
|
287
|
+
def _public_output(self,path):
|
|
288
|
+
"""Explicit user output destination, never a scientific writer bypass."""
|
|
289
|
+
from ._experimental import admission as A
|
|
290
|
+
scope=PUBLIC_SCOPE.get()
|
|
291
|
+
if scope is not None:scope.output(path)
|
|
292
|
+
if ACTIVE.get() is not None or A.CURRENT.get() is not None or not (getattr(self,'_public_user_run',False) or getattr(self,'_input_sha256',None)):
|
|
293
|
+
yield path;return
|
|
294
|
+
from ._experimental.runtime_io import WORKDIR, configure
|
|
295
|
+
target=Path(path).resolve();parent=target.parent
|
|
296
|
+
if not parent.is_dir():raise ValueError('Output parent directory must exist')
|
|
297
|
+
old=self._workdir;token=WORKDIR.set(WORKDIR.get());old_temp=tempfile.tempdir
|
|
298
|
+
try:
|
|
299
|
+
self._workdir=str(parent);configure(parent);yield target
|
|
300
|
+
finally:
|
|
301
|
+
self._workdir=old;tempfile.tempdir=old_temp;WORKDIR.reset(token)
|
|
302
|
+
|
|
303
|
+
def save(self,path,*,variant='all'):
|
|
304
|
+
with self._public_output(path) as p:return super().save(p,variant=variant)
|
|
305
|
+
|
|
306
|
+
def save_recipe(self,path,*,variant='selected'):
|
|
307
|
+
with self._public_output(path) as p:return super().save_recipe(p,variant=variant)
|
|
308
|
+
|
|
309
|
+
def export_python(self,path,*,variant='selected'):
|
|
310
|
+
with self._public_output(path) as p:return super().export_python(p,variant=variant)
|
|
311
|
+
|
|
312
|
+
def export_algorithm(self,path):
|
|
313
|
+
with self._public_output(path) as p:return super().export_algorithm(p)
|
|
314
|
+
|
|
315
|
+
def set_params(self, **values):
|
|
316
|
+
"""Update implemented public controls without resetting evolution limits.
|
|
317
|
+
|
|
318
|
+
The inherited legacy setter reconstructed the object without the
|
|
319
|
+
public mutation limit/research operator. It also rejected canonical
|
|
320
|
+
verbosity/use_optuna names. Serialization keeps its legacy constructor
|
|
321
|
+
projection; executed evolution limits remain in the persisted trace.
|
|
322
|
+
Validation is transactional: an invalid change preserves fitted state.
|
|
323
|
+
"""
|
|
324
|
+
options=super().get_params()
|
|
325
|
+
allowed=set(options)|{'mode','operators','verbosity','use_optuna','max_mutations'}
|
|
326
|
+
if set(values)-allowed:
|
|
327
|
+
raise ValueError('Unknown estimator parameter: '+', '.join(sorted(set(values)-allowed)))
|
|
328
|
+
options['max_mutations']=self._max_mutations
|
|
329
|
+
if self._research_growth:
|
|
330
|
+
options.pop('families');options.pop('morphing')
|
|
331
|
+
options.update(mode='research',operators=('growth_policy:depthwise',))
|
|
332
|
+
# Remove an inherited legacy spelling only when it was not supplied
|
|
333
|
+
# explicitly in the same update. Explicit conflicts remain errors.
|
|
334
|
+
for canonical,legacy in [('verbosity','verbose'),('use_optuna','tuning')]:
|
|
335
|
+
if canonical in values and legacy not in values:options.pop(legacy,None)
|
|
336
|
+
if 'mode' in values:
|
|
337
|
+
for legacy in ('families','morphing'):
|
|
338
|
+
if legacy not in values:options.pop(legacy,None)
|
|
339
|
+
if values['mode']!='research' and 'operators' not in values:
|
|
340
|
+
options.pop('operators',None)
|
|
341
|
+
options.update(values)
|
|
342
|
+
replacement=type(self)(**options)
|
|
343
|
+
self.__dict__=replacement.__dict__
|
|
344
|
+
return self
|
|
345
|
+
|
|
346
|
+
def export_standalone(self, path):
|
|
347
|
+
"""Export the existing dependency-free inference predictor, not training."""
|
|
348
|
+
return self.export_python(path)
|
|
349
|
+
|
|
350
|
+
@classmethod
|
|
351
|
+
def load(cls, path, *, sha256=None, workdir=None):
|
|
352
|
+
scope = PUBLIC_SCOPE.get()
|
|
353
|
+
if scope is not None:
|
|
354
|
+
scope.output(path)
|
|
355
|
+
if workdir is not None:
|
|
356
|
+
scope.output(workdir)
|
|
357
|
+
source = Path(path).resolve()
|
|
358
|
+
if not source.is_file() or not 0 < source.stat().st_size <= 32 * 1024 ** 2:
|
|
359
|
+
raise ValueError('A bounded model file is required')
|
|
360
|
+
if sha256 is None:
|
|
361
|
+
with source.open('rb') as stream:
|
|
362
|
+
raw = stream.read(32 * 1024 ** 2 + 1)
|
|
363
|
+
if not 0 < len(raw) <= 32 * 1024 ** 2:
|
|
364
|
+
raise ValueError('A bounded model file is required')
|
|
365
|
+
expected = hashlib.sha256(raw).hexdigest()
|
|
366
|
+
del raw
|
|
367
|
+
else:
|
|
368
|
+
expected = sha256
|
|
369
|
+
from ._experimental.runtime_io import WORKDIR
|
|
370
|
+
token = WORKDIR.set(WORKDIR.get())
|
|
371
|
+
old_temp = tempfile.tempdir
|
|
372
|
+
workspace = None
|
|
373
|
+
try:
|
|
374
|
+
if workdir is None:
|
|
375
|
+
workspace = OwnedTemporaryDirectory(prefix='morphtree-model-')
|
|
376
|
+
workdir = workspace.name
|
|
377
|
+
model = super().load(source, sha256=expected, workdir=workdir)
|
|
378
|
+
if workspace is not None:
|
|
379
|
+
model._loaded_workspace = workspace
|
|
380
|
+
return model
|
|
381
|
+
except BaseException as primary:
|
|
382
|
+
if workspace is not None:
|
|
383
|
+
try:workspace.cleanup()
|
|
384
|
+
except BaseException as cleanup_error:BaseException.add_note(primary,'Owned legacy workspace cleanup also failed: '+type(cleanup_error).__name__)
|
|
385
|
+
raise
|
|
386
|
+
finally:
|
|
387
|
+
tempfile.tempdir = old_temp
|
|
388
|
+
WORKDIR.reset(token)
|
morphtree/api_compat.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Public2.0 call contracts; historical weights keep their original decoder."""
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def validate_workdir(value):
|
|
7
|
+
if value is None:return None
|
|
8
|
+
if not isinstance(value,(str,os.PathLike)):
|
|
9
|
+
raise ValueError('Existing local working directory required')
|
|
10
|
+
value=os.fspath(value)
|
|
11
|
+
if not isinstance(value,str) or not value:raise ValueError('Existing local working directory required')
|
|
12
|
+
from ._experimental.admission import safe_path
|
|
13
|
+
path=Path(value)
|
|
14
|
+
# safe_path rejects links/reparse ancestors before resolving them.
|
|
15
|
+
checked=safe_path(path.absolute(),path.absolute())
|
|
16
|
+
if not checked.is_dir():raise ValueError('Existing local working directory required')
|
|
17
|
+
return checked
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class HistoricalModel:
|
|
21
|
+
"""A real subclass of the new facade with unchanged legacy learned state.
|
|
22
|
+
|
|
23
|
+
Public inspection/IO use the original decoder/engine. A new fit uses the
|
|
24
|
+
scalable controller and only replaces that historical state on success.
|
|
25
|
+
"""
|
|
26
|
+
_historical_delegates=frozenset(('predict','predict_proba','score','save',
|
|
27
|
+
'report','get_search_report','get_algorithm_program','get_recipe',
|
|
28
|
+
'get_mutation_history','cost_report','available_variants','save_recipe',
|
|
29
|
+
'export_python','export_standalone','export_algorithm','task_','classes_',
|
|
30
|
+
'n_features_in_','feature_names_in_','best_family_','best_params_',
|
|
31
|
+
'stop_reason_','_engine','_loaded_workspace'))
|
|
32
|
+
|
|
33
|
+
def __getattribute__(self,name):
|
|
34
|
+
state=object.__getattribute__(self,'__dict__')
|
|
35
|
+
legacy=state.get('_historical_model')
|
|
36
|
+
if legacy is not None and name in HistoricalModel._historical_delegates:
|
|
37
|
+
if name=='_loaded_workspace':return getattr(legacy._engine,name)
|
|
38
|
+
return getattr(legacy,name)
|
|
39
|
+
return super().__getattribute__(name)
|
|
40
|
+
|
|
41
|
+
def __sklearn_is_fitted__(self):
|
|
42
|
+
return '_historical_model' in self.__dict__ or super().__sklearn_is_fitted__()
|
|
43
|
+
|
|
44
|
+
def fit(self,X,y):
|
|
45
|
+
historical=self.__dict__.pop('_historical_model',None)
|
|
46
|
+
try:return super().fit(X,y)
|
|
47
|
+
except BaseException:
|
|
48
|
+
if historical is not None:self.__dict__['_historical_model']=historical
|
|
49
|
+
raise
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def wrap_historical(legacy,facade,memory_budget):
|
|
53
|
+
from .scalable import MorphTreeClassifier,MorphTreeRegressor
|
|
54
|
+
if not hasattr(legacy,'_engine'):
|
|
55
|
+
from . import estimators
|
|
56
|
+
kind=estimators.MorphTreeRegressor if legacy.task_=='regression' else estimators.MorphTreeClassifier
|
|
57
|
+
wrapped=kind.__new__(kind);wrapped._engine=legacy;legacy=wrapped
|
|
58
|
+
kind=MorphTreeRegressor if legacy.task_=='regression' else MorphTreeClassifier
|
|
59
|
+
params=legacy.get_params()
|
|
60
|
+
params.update(memory_budget=memory_budget)
|
|
61
|
+
# This is an ordinary Python subclass, not __class__ spoofing or a rewrite
|
|
62
|
+
# of weights. get_params describes its next scalable refit, not old training.
|
|
63
|
+
cls=type('Historical'+kind.__name__,(HistoricalModel,kind),{'__module__':__name__})
|
|
64
|
+
obj=cls(**params);obj._historical_model=legacy
|
|
65
|
+
return obj
|
morphtree/backend.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Reference backend: standard library only, column-major compact bins."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from array import array
|
|
5
|
+
import math
|
|
6
|
+
from .metrics import sigmoid, softmax
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class PythonBackend:
|
|
10
|
+
name = "python-stdlib"
|
|
11
|
+
|
|
12
|
+
def bin(self, binner, X):
|
|
13
|
+
return binner.transform(X)
|
|
14
|
+
|
|
15
|
+
def gradients(self, raw, y):
|
|
16
|
+
probabilities = [sigmoid(v) for v in raw]
|
|
17
|
+
gradient = array("d", (p - label for p, label in zip(probabilities, y)))
|
|
18
|
+
hessian = array("d", (max(p * (1 - p), 1e-12) for p in probabilities))
|
|
19
|
+
return probabilities, gradient, hessian
|
|
20
|
+
|
|
21
|
+
def probabilities(self, raw):
|
|
22
|
+
return [sigmoid(v) for v in raw]
|
|
23
|
+
|
|
24
|
+
def regression_gradients(self, raw, y):
|
|
25
|
+
# L = 0.5 * (prediction - target)^2: g = prediction - target, h = 1.
|
|
26
|
+
return raw, array('d', (v - target for v, target in zip(raw, y))), array('d', [1.0]) * len(y)
|
|
27
|
+
|
|
28
|
+
def multiclass_raw(self, prior, n):
|
|
29
|
+
return [list(prior) for _ in range(n)]
|
|
30
|
+
|
|
31
|
+
def multiclass_probabilities(self, raw, strategy, *, normalize=True):
|
|
32
|
+
if strategy == 'softmax':
|
|
33
|
+
return [softmax(row) for row in raw]
|
|
34
|
+
probabilities = [[sigmoid(float(v)) for v in row] for row in raw]
|
|
35
|
+
if normalize:
|
|
36
|
+
for row in probabilities:
|
|
37
|
+
total = math.fsum(row)
|
|
38
|
+
for j in range(len(row)):
|
|
39
|
+
row[j] /= total
|
|
40
|
+
return probabilities
|
|
41
|
+
|
|
42
|
+
def multiclass_gradient_column(self, probabilities, y, column, strategy):
|
|
43
|
+
# D=2*diag(p*(1-p)) majorizes diag(p)-p*p^T. It is NOT the exact Hessian.
|
|
44
|
+
factor = 2. if strategy == 'softmax' else 1.
|
|
45
|
+
return (array('d', (row[column] - (label == column) for row, label in zip(probabilities, y))),
|
|
46
|
+
array('d', (max(factor * row[column] * (1-row[column]), 1e-12) for row in probabilities)))
|
|
47
|
+
|
|
48
|
+
def multiclass_update(self, raw, column, step, prediction):
|
|
49
|
+
for row, value in zip(raw, prediction):
|
|
50
|
+
row[column] += step * float(value)
|
|
51
|
+
|
|
52
|
+
def center_multiclass(self, raw):
|
|
53
|
+
for row in raw:
|
|
54
|
+
mean = math.fsum(row) / len(row)
|
|
55
|
+
for j in range(len(row)):
|
|
56
|
+
row[j] -= mean
|
|
57
|
+
|
|
58
|
+
def node_statistics(self, Xb, ids, g, h, u, features, need_missing, need_uncertainty):
|
|
59
|
+
count = max(len(ids), 1)
|
|
60
|
+
missing = sum(any(Xb[f][i] == 0 for f in features) for i in ids) / count if need_missing else 0.0
|
|
61
|
+
uncertainty = math.fsum(u[i] for i in ids) / count if need_uncertainty else 0.0
|
|
62
|
+
return math.fsum(g[i] for i in ids), math.fsum(h[i] for i in ids), uncertainty, missing
|
|
63
|
+
|
|
64
|
+
def histogram(self, column, indices, g, h, r, u, size):
|
|
65
|
+
result = [array("d", [0.0]) * size for _ in range(5)]
|
|
66
|
+
counts, gs, hs, rs, us = result
|
|
67
|
+
for i in indices:
|
|
68
|
+
b = column[i]
|
|
69
|
+
counts[b] += 1
|
|
70
|
+
gs[b] += g[i]
|
|
71
|
+
hs[b] += h[i]
|
|
72
|
+
rs[b] += r[i]
|
|
73
|
+
us[b] += u[i]
|
|
74
|
+
return result
|
|
75
|
+
|
|
76
|
+
def subtract(self, parent, child):
|
|
77
|
+
result = [[a - b for a, b in zip(pa, ch)] for pa, ch in zip(parent, child)]
|
|
78
|
+
result[0] = [max(0.0, c) for c in result[0]]
|
|
79
|
+
result[2] = [max(0.0, h) for h in result[2]]
|
|
80
|
+
return result
|
|
81
|
+
|
|
82
|
+
def cumulative(self, histogram):
|
|
83
|
+
result = []
|
|
84
|
+
for row in histogram:
|
|
85
|
+
running, sums = 0.0, []
|
|
86
|
+
for value in row[1:]:
|
|
87
|
+
running += value
|
|
88
|
+
sums.append(running)
|
|
89
|
+
result.append(sums)
|
|
90
|
+
return result
|
|
91
|
+
|
|
92
|
+
def partition(self, column, ids, cut, missing_left):
|
|
93
|
+
left, right = [], []
|
|
94
|
+
for i in ids:
|
|
95
|
+
b = column[i]
|
|
96
|
+
goes_left = missing_left if b == 0 else b <= cut
|
|
97
|
+
(left if goes_left else right).append(i)
|
|
98
|
+
return left, right
|
|
99
|
+
|
|
100
|
+
def predict_tree(self, nodes, Xb):
|
|
101
|
+
n = len(Xb[0])
|
|
102
|
+
out = [0.0] * n
|
|
103
|
+
for row in range(n):
|
|
104
|
+
node_id = 0
|
|
105
|
+
for _ in range(len(nodes) + 1):
|
|
106
|
+
node = nodes[node_id]
|
|
107
|
+
if node.is_leaf:
|
|
108
|
+
out[row] = node.value
|
|
109
|
+
break
|
|
110
|
+
b = Xb[node.feature][row]
|
|
111
|
+
goes_left = node.missing_left if b == 0 else b <= node.threshold_bin
|
|
112
|
+
node_id = node.left if goes_left else node.right
|
|
113
|
+
else:
|
|
114
|
+
raise ValueError("Invalid cyclic tree")
|
|
115
|
+
return out
|
|
116
|
+
|
|
117
|
+
def add_update(self, raw, step, prediction):
|
|
118
|
+
return [r + step * v for r, v in zip(raw, prediction)]
|
|
119
|
+
|
|
120
|
+
def fast_split(self, hist, total, depth, genome, params):
|
|
121
|
+
if genome.split_program != ("OBJECTIVE_GAIN",):
|
|
122
|
+
return NotImplemented
|
|
123
|
+
# Canonical scalar arithmetic, specialized to the three consumed rows.
|
|
124
|
+
# Hoist regularization; do not allocate left/right lists for every cut.
|
|
125
|
+
counts, gradients, hessians = ([float(v) for v in row] for row in hist[:3])
|
|
126
|
+
n, G, H = total[:3]
|
|
127
|
+
factor = params.depth_reg_strength if genome.regularization_policy == "depth_scaled" else 0.
|
|
128
|
+
child_l2 = params.l2 * (1 + factor * (depth + 1))
|
|
129
|
+
parent_l2 = params.l2 * (1 + factor * depth)
|
|
130
|
+
l1 = params.l1
|
|
131
|
+
thresholded = math.copysign(max(abs(G)-l1, 0.), G)
|
|
132
|
+
parent_score = thresholded * thresholded / max(H+parent_l2, 1e-12)
|
|
133
|
+
directions = (True, False) if genome.missing_policy == "learned" else (genome.missing_policy == "left",)
|
|
134
|
+
if len(directions) == 2 and not any(row[0] for row in hist):
|
|
135
|
+
directions = directions[:1]
|
|
136
|
+
mn, mg, mh = counts[0], gradients[0], hessians[0]
|
|
137
|
+
cn = cg = ch = 0.
|
|
138
|
+
best = None
|
|
139
|
+
best_rounded = None
|
|
140
|
+
support, min_hessian, min_gain = params.min_samples_leaf, params.min_hessian, params.min_gain
|
|
141
|
+
for cut in range(len(counts)):
|
|
142
|
+
if cut:
|
|
143
|
+
cn += counts[cut]
|
|
144
|
+
cg += gradients[cut]
|
|
145
|
+
ch += hessians[cut]
|
|
146
|
+
for missing_left in directions:
|
|
147
|
+
ln = cn + (mn if missing_left else 0.)
|
|
148
|
+
lg = cg + (mg if missing_left else 0.)
|
|
149
|
+
lh = ch + (mh if missing_left else 0.)
|
|
150
|
+
rn, rg, rh = n-ln, G-lg, H-lh
|
|
151
|
+
if min(ln, rn) < support or min(lh, rh) < min_hessian:
|
|
152
|
+
continue
|
|
153
|
+
lt = math.copysign(max(abs(lg)-l1, 0.), lg)
|
|
154
|
+
rt = math.copysign(max(abs(rg)-l1, 0.), rg)
|
|
155
|
+
left_score = lt * lt / max(lh+child_l2, 1e-12)
|
|
156
|
+
right_score = rt * rt / max(rh+child_l2, 1e-12)
|
|
157
|
+
gain = 0.5 * (left_score + right_score - parent_score)
|
|
158
|
+
if math.isfinite(gain) and gain > min_gain:
|
|
159
|
+
rounded = round(gain, 12)
|
|
160
|
+
if best is None or rounded > best_rounded:
|
|
161
|
+
best, best_rounded = (gain, cut, missing_left), rounded
|
|
162
|
+
return best
|