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.
Files changed (79) hide show
  1. morphtree/__init__.py +28 -0
  2. morphtree/__main__.py +3 -0
  3. morphtree/_experimental/__init__.py +1 -0
  4. morphtree/_experimental/admission.py +364 -0
  5. morphtree/_experimental/base.py +215 -0
  6. morphtree/_experimental/component_cache_v2.py +263 -0
  7. morphtree/_experimental/evolution_report.py +307 -0
  8. morphtree/_experimental/execution_v2.py +697 -0
  9. morphtree/_experimental/facade.py +289 -0
  10. morphtree/_experimental/integrated.py +748 -0
  11. morphtree/_experimental/learning.py +418 -0
  12. morphtree/_experimental/manifest.json +204 -0
  13. morphtree/_experimental/numeric_fixture.py +21 -0
  14. morphtree/_experimental/parameter_contract.py +38 -0
  15. morphtree/_experimental/postfit.py +51 -0
  16. morphtree/_experimental/preprocessing.py +133 -0
  17. morphtree/_experimental/processes.py +191 -0
  18. morphtree/_experimental/producer.py +542 -0
  19. morphtree/_experimental/product.py +156 -0
  20. morphtree/_experimental/product_cli.py +43 -0
  21. morphtree/_experimental/roundtrip_v3.py +311 -0
  22. morphtree/_experimental/runtime_io.py +65 -0
  23. morphtree/_experimental/sampling.py +26 -0
  24. morphtree/_experimental/support.py +281 -0
  25. morphtree/_experimental/target.py +59 -0
  26. morphtree/_experimental/training.py +53 -0
  27. morphtree/_experimental/worker.py +682 -0
  28. morphtree/_installed.py +17 -0
  29. morphtree/_native/__init__.py +1 -0
  30. morphtree/_native/bin/win_amd64/morphtree_kernels.dll +0 -0
  31. morphtree/_native/buffers.py +82 -0
  32. morphtree/_native/loader.py +39 -0
  33. morphtree/_native/manifest.json +29 -0
  34. morphtree/_native/runtime.py +48 -0
  35. morphtree/_native/src/histogram.c +61 -0
  36. morphtree/_native/src/traversal.c +51 -0
  37. morphtree/api.py +388 -0
  38. morphtree/api_compat.py +65 -0
  39. morphtree/backend.py +162 -0
  40. morphtree/booster.py +252 -0
  41. morphtree/cli.py +52 -0
  42. morphtree/config.py +262 -0
  43. morphtree/control.py +20 -0
  44. morphtree/data.py +300 -0
  45. morphtree/diagnostics.py +158 -0
  46. morphtree/estimators.py +156 -0
  47. morphtree/examples.py +25 -0
  48. morphtree/exporting.py +224 -0
  49. morphtree/legacy_artifacts.py +32 -0
  50. morphtree/metrics.py +156 -0
  51. morphtree/model.py +1010 -0
  52. morphtree/mutations.py +205 -0
  53. morphtree/native_lab_backend.py +79 -0
  54. morphtree/numpy_backend.py +180 -0
  55. morphtree/optuna_tuner.py +154 -0
  56. morphtree/persistence.py +414 -0
  57. morphtree/prediction_output.py +35 -0
  58. morphtree/prepared_bins.py +82 -0
  59. morphtree/public.py +106 -0
  60. morphtree/resource_plan.py +170 -0
  61. morphtree/scalable.py +781 -0
  62. morphtree/scalable_backend.py +74 -0
  63. morphtree/scalable_checkpoint.py +129 -0
  64. morphtree/scalable_data.py +255 -0
  65. morphtree/scalable_receipts.py +92 -0
  66. morphtree/scalable_target.py +68 -0
  67. morphtree/scalable_variants.py +133 -0
  68. morphtree/sklearn_view.py +79 -0
  69. morphtree/source_markers.py +25 -0
  70. morphtree/species.py +47 -0
  71. morphtree/temporary.py +65 -0
  72. morphtree/training_fragment.py +130 -0
  73. morphtree/tree.py +231 -0
  74. morphtree/tuning.py +150 -0
  75. morphtree-2.1.0.dist-info/METADATA +212 -0
  76. morphtree-2.1.0.dist-info/RECORD +79 -0
  77. morphtree-2.1.0.dist-info/WHEEL +4 -0
  78. morphtree-2.1.0.dist-info/entry_points.txt +2 -0
  79. 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)
@@ -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