x-evolution 0.1.34__tar.gz → 0.1.35__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: x-evolution
3
- Version: 0.1.34
3
+ Version: 0.1.35
4
4
  Summary: x-evolution
5
5
  Project-URL: Homepage, https://pypi.org/project/x-evolution/
6
6
  Project-URL: Repository, https://github.com/lucidrains/x-evolution
@@ -191,4 +191,13 @@ Special thanks to [Pollen Robotics 🤗](https://www.pollen-robotics.com/) for t
191
191
  }
192
192
  ```
193
193
 
194
+ ```bibtex
195
+ @misc{dahal2026backprop,
196
+ title = {Dust: Pretraining Transformers Without Backpropagation},
197
+ author = {Dahal, Samip and Mandal, Bishwas and G{\"u}lbahar, Serdar and Vegesna, Akshay},
198
+ year = {2026},
199
+ url = {https://qlabs.sh/research/dust}
200
+ }
201
+ ```
202
+
194
203
  *Nothing makes sense except in the light of evolution* - Theodosius Dobzhansky
@@ -140,4 +140,13 @@ Special thanks to [Pollen Robotics 🤗](https://www.pollen-robotics.com/) for t
140
140
  }
141
141
  ```
142
142
 
143
+ ```bibtex
144
+ @misc{dahal2026backprop,
145
+ title = {Dust: Pretraining Transformers Without Backpropagation},
146
+ author = {Dahal, Samip and Mandal, Bishwas and G{\"u}lbahar, Serdar and Vegesna, Akshay},
147
+ year = {2026},
148
+ url = {https://qlabs.sh/research/dust}
149
+ }
150
+ ```
151
+
143
152
  *Nothing makes sense except in the light of evolution* - Theodosius Dobzhansky
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "x-evolution"
3
- version = "0.1.34"
3
+ version = "0.1.35"
4
4
  description = "x-evolution"
5
5
  authors = [
6
6
  { name = "Phil Wang", email = "lucidrains@gmail.com" }
@@ -52,6 +52,9 @@ test = [
52
52
  pythonpath = [
53
53
  "."
54
54
  ]
55
+ testpaths = [
56
+ "tests"
57
+ ]
55
58
 
56
59
  [build-system]
57
60
  requires = ["hatchling"]
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
- from typing import Callable, Literal
2
+ from typing import Literal, Any
3
+ from collections.abc import Callable
3
4
 
4
5
  from math import ceil
5
6
  from pathlib import Path
@@ -83,7 +84,7 @@ class EvoStrategy(Module):
83
84
  self,
84
85
  model: Module | list[Module],
85
86
  *,
86
- environment: Callable[[Module], float | int | Tensor], # the environment is simply a function that takes in the model and returns a fitness score
87
+ environment: Callable[[Module], Any] | dict[str, Callable[[Module], Any]], # reward function, or one per scope
87
88
  num_generations,
88
89
  noise_population_size = 30,
89
90
  learning_rate = 1e-3,
@@ -135,15 +136,20 @@ class EvoStrategy(Module):
135
136
 
136
137
  self.accelerate = accelerator
137
138
 
138
- # environment - with optional init
139
+ # environment - a single reward function, or a dict of one per scope
140
+ # a single environment can also return one reward per scope, as a dict, tuple, or list
139
141
 
140
142
  self.environment = environment
143
+ self.environments = environment if isinstance(environment, dict) else None
144
+
145
+ environments = list(self.environments.values()) if exists(self.environments) else [environment]
141
146
 
142
147
  if accelerator.is_main_process:
143
- pre_main_callback = getattr(environment, 'pre_main_callback', None)
144
- if callable(pre_main_callback):
145
- self.print('pre_main_callback detected on environment passed in and is invoked')
146
- pre_main_callback()
148
+ for one_environment in environments:
149
+ pre_main_callback = getattr(one_environment, 'pre_main_callback', None)
150
+ if callable(pre_main_callback):
151
+ self.print('pre_main_callback detected on environment passed in and is invoked')
152
+ pre_main_callback()
147
153
 
148
154
  self.wait_for_everyone()
149
155
 
@@ -172,6 +178,7 @@ class EvoStrategy(Module):
172
178
  # default to all parameters to optimize with evo strategy
173
179
 
174
180
  self.scopes = None
181
+ self.scope_names = None
175
182
 
176
183
  if isinstance(params_to_optimize, dict):
177
184
  self.scopes = dict()
@@ -179,12 +186,19 @@ class EvoStrategy(Module):
179
186
 
180
187
  for scope_name, scope_params in params_to_optimize.items():
181
188
  resolved_scope_params = self._resolve_params_to_names(scope_params)
182
- assert all([name in param_names for name in resolved_scope_params]), f'some parameters in scope {scope_name} are not found in the model'
189
+ assert all(name in param_names for name in resolved_scope_params), f'some parameters in scope {scope_name} are not found in the model'
183
190
  self.scopes[scope_name] = resolved_scope_params
184
191
  all_scoped_params |= resolved_scope_params
185
192
 
193
+ self.scope_names = list(self.scopes)
186
194
  params_to_optimize = list(all_scoped_params)
187
195
 
196
+ # a dict of environments gives one reward function per scope, so the keys must line up with the scopes
197
+
198
+ if exists(self.environments):
199
+ assert exists(self.scopes), 'when environment is a dict, params_to_optimize must also be a dict with the same scope names'
200
+ assert set(self.environments) == set(self.scopes), f'environment keys {list(self.environments)} must match scope names {list(self.scopes)}'
201
+
188
202
  params_to_optimize = default(params_to_optimize, param_names)
189
203
 
190
204
  # convert to names using the helper method
@@ -341,6 +355,34 @@ class EvoStrategy(Module):
341
355
  sigma_index = self.param_name_to_sigma_index[param_name]
342
356
  return self.sigmas[sigma_index]
343
357
 
358
+ @staticmethod
359
+ def _to_float(reward):
360
+ if isinstance(reward, Tensor):
361
+ return reward.mean().item()
362
+
363
+ return float(reward)
364
+
365
+ def _resolve_rewards(self, rewards, scope):
366
+ # a reward is a scalar, or one reward per scope as a dict, tuple, or list
367
+ # resolves to a float, or to a list of floats in scope order, with a scope picking out its own reward
368
+
369
+ if isinstance(rewards, (dict, list, tuple)):
370
+ assert exists(self.scope_names), 'environment returned one reward per scope, but no scopes were defined in params_to_optimize'
371
+
372
+ if isinstance(rewards, dict):
373
+ assert set(rewards) == set(self.scope_names), f'reward keys {list(rewards)} must match scope names {self.scope_names}'
374
+ rewards = [rewards[name] for name in self.scope_names]
375
+ else:
376
+ assert len(rewards) == len(self.scope_names), f'environment returned {len(rewards)} rewards, expected one per scope {self.scope_names}'
377
+ rewards = list(rewards)
378
+ else:
379
+ return self._to_float(rewards)
380
+
381
+ if exists(scope):
382
+ return self._to_float(rewards[self.scope_names.index(scope)])
383
+
384
+ return [self._to_float(reward) for reward in rewards]
385
+
344
386
  @torch.inference_mode()
345
387
  def evolve_(
346
388
  self,
@@ -474,6 +516,11 @@ class EvoStrategy(Module):
474
516
  ):
475
517
  verbose = default(verbose, self.verbose)
476
518
 
519
+ # a scope restricts the population and the update to that scope's parameters and reward, for interleaving scopes phase by phase
520
+ # otherwise a joint population is rolled out, each scope updated with its own reward when one is returned per scope
521
+
522
+ dynamic_params = exists(params_to_optimize)
523
+
477
524
  scoped_names = None
478
525
 
479
526
  if exists(scope):
@@ -493,7 +540,7 @@ class EvoStrategy(Module):
493
540
  # maybe sigmas
494
541
 
495
542
  if self.learned_noise_scale:
496
- self.sigmas = self.sigmas.to(self.device)
543
+ self.sigmas = self.sigmas.to(self.device)
497
544
 
498
545
  # get world size, rank, and determine if distributed
499
546
 
@@ -545,6 +592,7 @@ class EvoStrategy(Module):
545
592
  seeds_for_machine = seeds_for_population.chunk(world_size)[rank]
546
593
 
547
594
  fitnesses = []
595
+ rewards_by_scope = False
548
596
 
549
597
  # seeds
550
598
 
@@ -559,28 +607,38 @@ class EvoStrategy(Module):
559
607
  for noise_index, individual_seed in pbar:
560
608
 
561
609
  if noise_index >= pop_size:
562
- fitnesses.append([0., 0.] if self.mirror_sampling else 0.)
610
+ fitnesses.append(None)
563
611
  continue
564
612
 
565
613
  def get_fitness(negate = False):
614
+ nonlocal rewards_by_scope
615
+
566
616
  individual_param_seeds = with_seed(individual_seed.item())(randint)(0, MAX_SEED_VALUE, (self.num_params,))
567
617
  noise_config = dict(zip(self.param_names_to_optimize, individual_param_seeds.tolist()))
568
618
 
569
619
  if exists(scoped_names):
570
620
  noise_config = {k: v for k, v in noise_config.items() if k in scoped_names}
571
621
 
572
- noise_config_with_scale = dict()
573
- for param_name, seed in noise_config.items():
574
- noise_scale = self._get_noise_scale(param_name)
575
- noise_config_with_scale[param_name] = (seed, noise_scale)
622
+ noise_config_with_scale = {name: (seed, self._get_noise_scale(name)) for name, seed in noise_config.items()}
576
623
 
577
624
  with model.temp_add_noise_(noise_config_with_scale, negate = negate):
578
- fitness = with_seed(maybe_rollout_seed)(self.environment)(model)
579
625
 
580
- if isinstance(fitness, Tensor) and fitness.numel() > 1:
581
- fitness = fitness.mean().item()
582
- elif isinstance(fitness, Tensor):
583
- fitness = fitness.item()
626
+ # a dict of environments gives one reward function per scope
627
+ # otherwise the single environment is called once, returning a scalar, or one reward per scope
628
+
629
+ rollout = with_seed(maybe_rollout_seed)
630
+
631
+ if exists(self.environments) and not exists(scope):
632
+ fitness = [rollout(self.environments[name])(model) for name in self.scope_names]
633
+ else:
634
+ env = self.environments[scope] if exists(scope) and exists(self.environments) else self.environment
635
+ fitness = rollout(env)(model)
636
+
637
+ fitness = self._resolve_rewards(fitness, scope)
638
+
639
+ if isinstance(fitness, list):
640
+ assert not dynamic_params, 'per-scope rewards were returned, so pass `scope = ...` instead of `params_to_optimize = ...`'
641
+ rewards_by_scope = True
584
642
 
585
643
  return fitness
586
644
 
@@ -601,13 +659,30 @@ class EvoStrategy(Module):
601
659
  fitness_mirrored = get_fitness(negate = True)
602
660
 
603
661
  fitnesses.append([fitness, fitness_mirrored])
604
- avg_fitness = (fitness + fitness_mirrored) / 2
662
+
663
+ if isinstance(fitness, list):
664
+ avg_fitness = [(pos + neg) / 2 for pos, neg in zip(fitness, fitness_mirrored)]
665
+ else:
666
+ avg_fitness = (fitness + fitness_mirrored) / 2
605
667
 
606
668
  if exists(self.on_result):
607
669
  self.on_result(avg_fitness, pbar)
608
670
 
609
671
  pbar.close()
610
672
 
673
+ # whether rewards are per scope is discovered from the rollouts, and needs to be agreed on across machines
674
+
675
+ if is_distributed:
676
+ flag = tensor(float(rewards_by_scope), device = self.device)
677
+ rewards_by_scope = self.accelerate.reduce(flag, reduction = 'sum').item() > 0
678
+
679
+ # pad the population to a multiple of the world size
680
+
681
+ zero = [0.] * len(self.scope_names) if rewards_by_scope else 0.
682
+ padding = [zero, zero] if self.mirror_sampling else zero
683
+
684
+ fitnesses = [default(entry, padding) for entry in fitnesses]
685
+
611
686
  # normalize the fitness and weighted sum of all the noise is the update
612
687
 
613
688
  fitnesses = tensor(fitnesses, device = self.device, dtype = torch.float32)
@@ -632,9 +707,13 @@ class EvoStrategy(Module):
632
707
  self.print(f'[{generation}] fitnesses rejected')
633
708
  continue
634
709
 
635
- # pass fitnesses to evolve function
710
+ # one joint population - with per-scope rewards, each scope is updated with its own column
636
711
 
637
- self.evolve_(fitnesses, seeds_for_population, scoped_names=scoped_names)
712
+ if rewards_by_scope:
713
+ for name, scope_fitnesses in zip(self.scope_names, fitnesses.unbind(dim = -1)):
714
+ self.evolve_(scope_fitnesses, seeds_for_population, scoped_names = self.scopes[name])
715
+ else:
716
+ self.evolve_(fitnesses, seeds_for_population, scoped_names = scoped_names)
638
717
 
639
718
  # on generation callback
640
719
 
@@ -643,7 +722,15 @@ class EvoStrategy(Module):
643
722
 
644
723
  # log
645
724
 
646
- msg = f'[{generation}] average fitness: {fitnesses.mean():.3f} | fitness std: {fitnesses.std():.3f}'
725
+ if rewards_by_scope:
726
+ fitness_msg = ' | '.join(
727
+ f'{name}: {scope_fitnesses.mean():.3f}'
728
+ for name, scope_fitnesses in zip(self.scope_names, fitnesses.unbind(dim = -1))
729
+ )
730
+ else:
731
+ fitness_msg = f'{fitnesses.mean():.3f} | fitness std: {fitnesses.std():.3f}'
732
+
733
+ msg = f'[{generation}] average fitness: {fitness_msg}'
647
734
 
648
735
  if self.learned_noise_scale:
649
736
  packed_sigma, _ = pack(list(self.sigmas), '*')
File without changes
File without changes