last changes
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,4 +1,6 @@
|
||||
from neo4j import AsyncGraphDatabase
|
||||
from typing import LiteralString, cast
|
||||
|
||||
from neo4j import AsyncGraphDatabase, Query
|
||||
|
||||
NEO4J_CONSTRAINTS = [
|
||||
"CREATE CONSTRAINT cortex_id IF NOT EXISTS FOR (n:cortex) REQUIRE n.id IS UNIQUE",
|
||||
@@ -30,37 +32,129 @@ class Neo4jDB:
|
||||
return await s.run(cypher, **params)
|
||||
"""
|
||||
|
||||
async def run_read(self, cypher: str, **params):
|
||||
async def execute_write(self, work):
|
||||
"""
|
||||
|
||||
Method: execute_write
|
||||
|
||||
Description:
|
||||
This method is used to execute a write operation on the database using the provided work.
|
||||
|
||||
Parameters:
|
||||
self: The current instance of the class.
|
||||
work: The work to be executed as a write operation on the database.
|
||||
|
||||
"""
|
||||
async with self._driver.session(database=self._database) as session:
|
||||
return await session.execute_write(work)
|
||||
|
||||
async def run_read(self, cypher: LiteralString | Query, **params):
|
||||
"""
|
||||
|
||||
Method: run_read
|
||||
|
||||
Description:
|
||||
This method allows running a read operation with the provided cypher query and parameters using the underlying
|
||||
driver session. It returns the result of the read operation.
|
||||
|
||||
Parameters:
|
||||
- cypher: str or Query object representing the cypher query to be executed.
|
||||
- **params: Additional keyword arguments that represent parameters to be passed to the cypher query.
|
||||
|
||||
Returns:
|
||||
Result of the read operation based on the provided cypher query and parameters.
|
||||
|
||||
"""
|
||||
async with self._driver.session(database=self._database) as s:
|
||||
return await s.run(cypher, **params)
|
||||
|
||||
async def read_single(self, cypher: str, **params):
|
||||
async def read_single(self, cypher: LiteralString | Query, **params):
|
||||
"""
|
||||
Reads a single record from the database using the provided Cypher query and parameters.
|
||||
|
||||
:param cypher: The Cypher query to execute.
|
||||
:param params: Additional parameters to be passed to the query.
|
||||
|
||||
:return: A single record retrieved from the database based on the given Cypher query and parameters.
|
||||
"""
|
||||
async with self._driver.session(database=self._database) as s:
|
||||
res = await s.run(cypher, **params)
|
||||
return await res.single()
|
||||
|
||||
async def read_all(self, cypher: str, **params):
|
||||
async def read_all(self, cypher: LiteralString | Query, **params):
|
||||
"""
|
||||
Reads all records from the database based on the given cypher query and parameters.
|
||||
|
||||
Parameters:
|
||||
cypher (str): The Cypher query to execute.
|
||||
**params: Additional parameters to pass to the Cypher query.
|
||||
|
||||
Return Type:
|
||||
list: A list of retrieved records from the database.
|
||||
"""
|
||||
async with self._driver.session(database=self._database) as s:
|
||||
res = await s.run(cypher, **params)
|
||||
return [r async for r in res]
|
||||
|
||||
async def run_consume(self, cypher: str, **params):
|
||||
async def run_consume(self, cypher: LiteralString | Query, **params):
|
||||
"""
|
||||
|
||||
Run a Cypher query and consume the result.
|
||||
|
||||
Parameters:
|
||||
cypher : Union[LiteralString, Query]
|
||||
The Cypher query to be executed.
|
||||
**params : Any
|
||||
Keyword arguments for query parameters.
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
"""
|
||||
async with self._driver.session(database=self._database) as s:
|
||||
res = await s.run(cypher, **params)
|
||||
return await res.consume()
|
||||
|
||||
async def create_schema(self):
|
||||
"""
|
||||
Creates database schema by running Neo4j constraints queries.
|
||||
|
||||
Parameters:
|
||||
- self: instance of the class
|
||||
- database: name of the database to be used for creating schema
|
||||
|
||||
Returns:
|
||||
- None
|
||||
"""
|
||||
async with self._driver.session(database=self._database) as s:
|
||||
for stmt in NEO4J_CONSTRAINTS:
|
||||
await s.run(stmt)
|
||||
await s.run(cast(LiteralString, stmt))
|
||||
|
||||
async def purge_all_nodes(self):
|
||||
"""
|
||||
Purge all nodes in the database.
|
||||
|
||||
Method parameters:
|
||||
- None
|
||||
|
||||
Returns:
|
||||
- None
|
||||
"""
|
||||
async with self._driver.session(database=self._database) as s:
|
||||
await s.run("MATCH (n) DETACH DELETE n")
|
||||
|
||||
async def drop_schema(self):
|
||||
"""
|
||||
Drop the current schema by dropping all constraints in the database.
|
||||
|
||||
Parameters:
|
||||
None
|
||||
|
||||
Return Type:
|
||||
None
|
||||
"""
|
||||
async with self._driver.session(database=self._database) as s:
|
||||
res = await s.run("SHOW CONSTRAINTS")
|
||||
async for record in res:
|
||||
name = record["name"]
|
||||
await s.run(f"DROP CONSTRAINT {name} IF EXISTS")
|
||||
await s.run(cast(LiteralString, f"DROP CONSTRAINT {name} IF EXISTS"))
|
||||
|
||||
@@ -12,7 +12,6 @@ from mathema.actors.cortex import Cortex
|
||||
from mathema.actors.sensor import Sensor
|
||||
from mathema.actors.neuron import Neuron
|
||||
from mathema.actors.actuator import Actuator
|
||||
from mathema.scape.scape import XorScape
|
||||
from mathema.scape.car_racing import CarRacingScape
|
||||
from mathema.envs.openai_car_racing import CarRacing
|
||||
|
||||
@@ -20,6 +19,24 @@ log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Exoself(Actor):
|
||||
"""
|
||||
Exoself actor coordinating genotype-driven agent evaluation and learning.
|
||||
|
||||
The Exoself represents the *outer control loop* of an agent in the mathema
|
||||
framework. It is responsible for:
|
||||
- loading a genotype snapshot from persistent storage (Neo4j),
|
||||
- constructing the executable phenotype (Sensors, Neurons, Actuators,
|
||||
Cortex, and Scape),
|
||||
- running repeated evaluation episodes,
|
||||
- applying evolutionary weight perturbations,
|
||||
- tracking and reporting fitness statistics,
|
||||
- persisting improved parameters back to the genotype store.
|
||||
|
||||
Conceptually, Exoself corresponds to the “body/executive self” around a
|
||||
cortex:
|
||||
- the Cortex handles step-by-step execution and fitness accumulation,
|
||||
- the Exoself handles episode-level control, learning, and persistence.
|
||||
"""
|
||||
def __init__(self, genotype: Dict[str, Any], file_name: Optional[str] = None):
|
||||
super().__init__("Exoself")
|
||||
self.monitor = None
|
||||
@@ -46,7 +63,14 @@ class Exoself(Actor):
|
||||
self._perturbed: List[Neuron] = []
|
||||
|
||||
@classmethod
|
||||
async def start(cls, agent_id: str, monitor) -> "Exoself":
|
||||
async def start(cls, agent_id: str, monitor):
|
||||
"""
|
||||
|
||||
Method start takes agent_id and monitor as parameters and is a class method. It initializes some attributes of
|
||||
the class and creates a task to run the _runner coroutine. If an exception is caught during execution, a placeholder
|
||||
_Dummy class is returned.
|
||||
|
||||
"""
|
||||
try:
|
||||
g = await load_genotype_snapshot(agent_id)
|
||||
except Exception as e:
|
||||
@@ -73,8 +97,8 @@ class Exoself(Actor):
|
||||
elapsed = 0.0
|
||||
try:
|
||||
fitness, evals, cycles, elapsed = await self.train_until_stop()
|
||||
except Exception as e:
|
||||
log.error(f"[Exoself {self.agent_id}] CRASH in train_until_stop(): {e!r}")
|
||||
except Exception as err:
|
||||
log.error(f"[Exoself {self.agent_id}] CRASH in train_until_stop(): {err!r}")
|
||||
fitness = float("-inf")
|
||||
evals = int(self.eval_acc)
|
||||
cycles = int(self.cycle_acc)
|
||||
@@ -82,8 +106,8 @@ class Exoself(Actor):
|
||||
finally:
|
||||
try:
|
||||
await monitor.cast(("terminated", self.agent_id, fitness, evals, cycles, elapsed))
|
||||
except Exception as e:
|
||||
log.error(f"[Exoself {self.agent_id}] FAILED to notify monitor: {e!r}")
|
||||
except Exception as err:
|
||||
log.error(f"[Exoself {self.agent_id}] FAILED to notify monitor: {err!r}")
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
self._runner_task = loop.create_task(_runner(), name=f"Exoself-{self.agent_id}")
|
||||
@@ -96,6 +120,10 @@ class Exoself(Actor):
|
||||
return Exoself(g, file_name=path)
|
||||
|
||||
async def run(self):
|
||||
"""
|
||||
run loop of the exoself. Builds the network (mapping from genotype to phenotype=
|
||||
and waits for messages of the cortex.
|
||||
"""
|
||||
self.build_pid_map_and_spawn()
|
||||
|
||||
self._link_cortex()
|
||||
@@ -116,6 +144,20 @@ class Exoself(Actor):
|
||||
return
|
||||
|
||||
async def run_evaluation(self):
|
||||
"""
|
||||
|
||||
Description:
|
||||
Method to run evaluation of exoself by building network and linking the components,
|
||||
spawning PID map, linking cortex, and running sensor, neuron, actuator actors.
|
||||
It processes messages from the inbox and terminates upon specific tags.
|
||||
|
||||
Parameters:
|
||||
None
|
||||
|
||||
Return:
|
||||
Tuple containing evaluation results in the format (fitness: float, flag: int, cycles: int, elapsed: float)
|
||||
|
||||
"""
|
||||
log.debug(f"exoself: build network and link...")
|
||||
self.build_pid_map_and_spawn()
|
||||
log.debug(f"exoself: link cortex...")
|
||||
@@ -140,6 +182,17 @@ class Exoself(Actor):
|
||||
return float("-inf"), 0, 0, 0.0
|
||||
|
||||
def build_pid_map_and_spawn(self):
|
||||
"""
|
||||
|
||||
Builds the PID map for the Cortex actor and spawns Neuron, Actuator, and Sensor actors.
|
||||
|
||||
Parameters:
|
||||
- self: reference to the class instance
|
||||
|
||||
Returns:
|
||||
- None
|
||||
|
||||
"""
|
||||
cx = self.g["cortex"]
|
||||
self.cx_actor = Cortex(
|
||||
cid=cx["id"],
|
||||
@@ -254,6 +307,7 @@ class Exoself(Actor):
|
||||
return []
|
||||
|
||||
def _link_cortex(self):
|
||||
|
||||
self.cx_actor.sensors = [a for a in self.sensor_actors if a]
|
||||
self.cx_actor.neurons = [a for a in self.neuron_actors if a]
|
||||
self.cx_actor.actuators = [a for a in self.actuator_actors if a]
|
||||
@@ -263,6 +317,23 @@ class Exoself(Actor):
|
||||
self.tasks.append(asyncio.create_task(self.cx_actor.run()))
|
||||
|
||||
async def train_until_stop(self):
|
||||
"""
|
||||
|
||||
train_until_stop method runs the training until the stop condition is met. It builds the PID map and spawns
|
||||
necessary components, including sensor actors, neuron actors, and actuator actors. If an actuator scape is present,
|
||||
it runs the actuator scape as well.
|
||||
|
||||
The method continuously waits for incoming messages from the inbox and processes them based on the message tag.
|
||||
If the tag is "evaluation_completed," it calls the _on_evaluation_completed method with the received fitness, cycles,
|
||||
and elapsed time. If the _on_evaluation_completed method returns a dictionary, the method returns a tuple containing
|
||||
the highest fitness, evaluation accuracy, cycle accuracy, and time accuracy.
|
||||
|
||||
If the message tag is "terminate," the method calls the _terminate_all method to stop the training process and
|
||||
returns a tuple with negative infinity for fitness and zeros for other metrics.
|
||||
|
||||
This method does not return any value explicitly during normal training execution.
|
||||
|
||||
"""
|
||||
self.build_pid_map_and_spawn()
|
||||
self._link_cortex()
|
||||
|
||||
@@ -291,6 +362,30 @@ class Exoself(Actor):
|
||||
return float("-inf"), 0, 0, 0.0
|
||||
|
||||
async def _on_evaluation_completed(self, fitness: float, cycles: int, elapsed: float):
|
||||
"""
|
||||
This method _on_evaluation_completed is an asynchronous function that handles the completion
|
||||
of an evaluation process.
|
||||
|
||||
Parameters:
|
||||
- fitness: a float representing the fitness value obtained from the evaluation process.
|
||||
- cycles: an integer indicating the number of cycles involved in the evaluation.
|
||||
- elapsed: a float representing the elapsed time for the evaluation process.
|
||||
|
||||
This method updates internal counters and logs information about the evaluation process. It also performs
|
||||
actions based on the evaluation results, such as updating the highest fitness value, backing up weights,
|
||||
or restoring weights of neuron actors.
|
||||
|
||||
If the number of attempts reaches the maximum allowed attempts, it stops the evaluation process,
|
||||
backs up the genotype, terminates all actors, and returns a dictionary containing information
|
||||
about the best fitness value, evaluation count, cycle count, and accumulated time.
|
||||
|
||||
Finally, it calculates the perturbation probability, selects a subset of neuron actors for weight perturbation,
|
||||
sends perturbation commands to selected neuron actors, and reactivates the cx_actor.
|
||||
|
||||
Note: This method does not have a return statement for successful execution. If an error occurs during the
|
||||
episode_done message sending, it will ignore the exception. No additional
|
||||
errors or exceptions are caught or handled in this method.
|
||||
"""
|
||||
self.eval_acc += 1
|
||||
self.cycle_acc += int(cycles)
|
||||
self.time_acc += float(elapsed)
|
||||
|
||||
@@ -12,6 +12,16 @@ def generate_id() -> str:
|
||||
|
||||
|
||||
def get_InitSensor(morphology: MorphologyType):
|
||||
"""
|
||||
Return the initial sensor configuration for a given morphology.
|
||||
|
||||
This helper selects a minimal starting set of sensors used to bootstrap a
|
||||
new agent's morphology. It resolves the full sensor list for the provided
|
||||
morphology and returns a list containing only the first sensor entry.
|
||||
|
||||
Raises:
|
||||
ValueError: If the resolved morphology provides no sensors.
|
||||
"""
|
||||
sensors = get_Sensors(morphology)
|
||||
if not sensors:
|
||||
log.error("Morphology has no sensors.")
|
||||
@@ -20,6 +30,17 @@ def get_InitSensor(morphology: MorphologyType):
|
||||
|
||||
|
||||
def get_InitActuator(morphology: MorphologyType):
|
||||
"""
|
||||
Return the initial actuator configuration for a given morphology.
|
||||
|
||||
This helper selects a minimal starting set of actuators used to bootstrap
|
||||
a new agent's morphology. It resolves the full actuator list for the
|
||||
provided morphology and returns a list containing only the first actuator
|
||||
entry.
|
||||
|
||||
Raises:
|
||||
ValueError: If the resolved morphology provides no actuators.
|
||||
"""
|
||||
actuators = get_Actuators(morphology)
|
||||
if not actuators:
|
||||
log.error("Morphology has no actuators.")
|
||||
@@ -28,22 +49,74 @@ def get_InitActuator(morphology: MorphologyType):
|
||||
|
||||
|
||||
def get_Sensors(morphology: MorphologyType) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Resolve and return the list of sensor specifications for a morphology.
|
||||
|
||||
The morphology may be provided as:
|
||||
- a callable that accepts a kind string ("sensors" or "actuators"),
|
||||
- a registered string key mapping to a known morphology implementation,
|
||||
- or a module-like object exposing a callable 'xor_mimic' function.
|
||||
|
||||
Args:
|
||||
morphology: Morphology selector (callable, string key, or module-like).
|
||||
|
||||
Returns:
|
||||
A list of sensor specification dictionaries, each describing a sensor
|
||||
actor (e.g., name, vector length, and associated scape).
|
||||
"""
|
||||
fn = _resolve_morphology(morphology)
|
||||
return fn("sensors")
|
||||
|
||||
|
||||
def get_Actuators(morphology: MorphologyType) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Resolve and return the list of actuator specifications for a morphology.
|
||||
|
||||
The morphology may be provided as:
|
||||
- a callable that accepts a kind string ("sensors" or "actuators"),
|
||||
- a registered string key mapping to a known morphology implementation,
|
||||
- or a module-like object exposing a callable 'xor_mimic' function.
|
||||
|
||||
Args:
|
||||
morphology: Morphology selector (callable, string key, or module-like).
|
||||
|
||||
Returns:
|
||||
A list of actuator specification dictionaries, each describing an
|
||||
actuator actor (e.g., name, vector length, and associated scape).
|
||||
"""
|
||||
fn = _resolve_morphology(morphology)
|
||||
return fn("actuators")
|
||||
|
||||
|
||||
def _resolve_morphology(morphology: MorphologyType) -> Callable[[str], List[Dict[str, Any]]]:
|
||||
"""
|
||||
Resolve a morphology selector into a callable that can produce sensor or actuator specs.
|
||||
|
||||
This function normalizes different morphology representations into a
|
||||
single callable interface: fn(kind) -> List[Dict[str, Any]]. Supported
|
||||
inputs are:
|
||||
|
||||
- A callable: returned as-is.
|
||||
- A string key: looked up in a registry of known morphologies.
|
||||
- A module-like object: if it exposes a callable attribute 'xor_mimic',
|
||||
that callable is used.
|
||||
|
||||
Args:
|
||||
morphology: Morphology selector (callable, string key, or module-like).
|
||||
|
||||
Returns:
|
||||
A callable that accepts "sensors" or "actuators" and returns the
|
||||
corresponding specification list.
|
||||
|
||||
Raises:
|
||||
ValueError: If a string key is provided but not registered.
|
||||
TypeError: If morphology cannot be resolved to a valid callable.
|
||||
"""
|
||||
if callable(morphology):
|
||||
return morphology
|
||||
|
||||
if isinstance(morphology, str):
|
||||
reg = {
|
||||
"xor_mimic": xor_mimic,
|
||||
"car_racing_features": car_racing_features
|
||||
}
|
||||
if morphology in reg:
|
||||
@@ -62,31 +135,23 @@ def _resolve_morphology(morphology: MorphologyType) -> Callable[[str], List[Dict
|
||||
raise TypeError("morphology must be a callable, a module with 'xor_mimic', or a registered string key")
|
||||
|
||||
|
||||
def xor_mimic(kind: str) -> List[Dict[str, Any]]:
|
||||
if kind == "sensors":
|
||||
return [
|
||||
{
|
||||
"name": "xor_GetInput",
|
||||
"vector_length": 2,
|
||||
"scape": "xor_sim"
|
||||
}
|
||||
]
|
||||
elif kind == "actuators":
|
||||
return [
|
||||
{
|
||||
"name": "xor_SendOutput",
|
||||
"vector_length": 1,
|
||||
"scape": "xor_sim"
|
||||
}
|
||||
]
|
||||
else:
|
||||
log.error(f"xor_mimic: unsupported kind '{kind}', expected 'sensors' or 'actuators'")
|
||||
raise ValueError(f"xor_mimic: unsupported kind '{kind}', expected 'sensors' or 'actuators'")
|
||||
|
||||
|
||||
def car_racing_features(kind: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
car racing morphology
|
||||
Provide a feature-based CarRacing morphology specification.
|
||||
|
||||
This morphology exposes:
|
||||
- One sensor ("car_GetFeatures") producing a fixed-length feature vector
|
||||
derived from a look-ahead horizon plus additional scalar features.
|
||||
- One actuator ("car_ApplyAction") consuming a 3-element action vector.
|
||||
|
||||
Args:
|
||||
kind: Either "sensors" or "actuators".
|
||||
|
||||
Returns:
|
||||
A list containing a single specification dictionary for the requested kind.
|
||||
|
||||
Raises:
|
||||
ValueError: If kind is not "sensors" or "actuators".
|
||||
"""
|
||||
LOOK_AHEAD = 10
|
||||
feature_len = LOOK_AHEAD + 6
|
||||
|
||||
@@ -21,31 +21,17 @@ from mathema.genotype.neo4j.genotype import (
|
||||
delete_agent,
|
||||
update_fingerprint,
|
||||
)
|
||||
from mathema.genotype.neo4j.genotype_mutator import GenotypeMutator
|
||||
from mathema.genotype.neo4j.genotype_mutator_tx import GenotypeMutator
|
||||
from mathema.utils import stats
|
||||
from mathema.core.exoself import Exoself
|
||||
from mathema.settings import (EFF, SURVIVAL_PERCENTAGE, SPECIE_SIZE_LIMIT,
|
||||
INIT_SPECIE_SIZE, GENERATION_LIMIT, EVALUATIONS_LIMIT,
|
||||
FITNESS_GOAL, INIT_POPULATION_ID)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
OpTag = Literal["continue", "pause", "done"]
|
||||
SelectionAlgorithm = Literal["competition", "top3"]
|
||||
|
||||
EFF: float = 0.05
|
||||
SURVIVAL_PERCENTAGE: float = 0.5
|
||||
SPECIE_SIZE_LIMIT: int = 10
|
||||
INIT_SPECIE_SIZE: int = 10
|
||||
|
||||
GENERATION_LIMIT: int = 1000
|
||||
EVALUATIONS_LIMIT: int = 100_000
|
||||
FITNESS_GOAL: float = 6000
|
||||
|
||||
INIT_POPULATION_ID: str = "test"
|
||||
INIT_OP_MODE: str = "gt"
|
||||
INIT_SELECTION_ALGO: SelectionAlgorithm = "competition"
|
||||
INIT_CONSTRAINTS: list[dict] = [
|
||||
{"morphology": "xor_mimic", "neural_afs": ["tanh"]},
|
||||
]
|
||||
|
||||
EXOSELF_START: Optional[Callable[[str, "PopulationMonitor"], Awaitable[Any]]] = None
|
||||
|
||||
|
||||
@@ -92,6 +78,23 @@ class MonitorState:
|
||||
|
||||
|
||||
async def _population_aggregate(population_id: str) -> dict:
|
||||
"""
|
||||
Retrieve fitness values for agents in a population and calculate aggregate statistics.
|
||||
|
||||
Parameters:
|
||||
population_id (str): The unique identifier of the population.
|
||||
|
||||
Returns:
|
||||
Dictionary: A dictionary containing the following aggregate statistics:
|
||||
- "cum_fitness": Sum of all fitness values.
|
||||
- "avg": Average fitness value.
|
||||
- "std": Standard deviation of fitness values.
|
||||
- "best": Maximum fitness value.
|
||||
- "min": Minimum fitness value.
|
||||
- "n": Number of fitness values.
|
||||
- "agents": Number of agents in the population.
|
||||
|
||||
"""
|
||||
rows = await _read_all("""
|
||||
MATCH (a:agent {population_id:$pid})
|
||||
RETURN collect(coalesce(toFloat(a.fitness),0.0)) AS fs
|
||||
@@ -116,12 +119,170 @@ async def _population_aggregate(population_id: str) -> dict:
|
||||
}
|
||||
|
||||
|
||||
class PopulationMonitor:
|
||||
"""
|
||||
Orchestriert Generationen: spawn -> warten -> selektieren/mutieren -> next.
|
||||
Expects exoself.start(agent_id, monitor) and exoself will cast(("terminated", aid, fitness, eval_acc, cycle_acc, time_acc)).
|
||||
async def _best_fitness_in_population(population_id: str) -> float:
|
||||
"""
|
||||
|
||||
Get the best fitness score in a given population based on the maximum fitness value of all agents.
|
||||
|
||||
Parameters:
|
||||
- population_id (str): The ID of the population to search for.
|
||||
|
||||
Returns:
|
||||
- float: The best fitness value found in the population, or 0.0 if no fitness values are found.
|
||||
|
||||
"""
|
||||
rows = await _read_all("""
|
||||
MATCH (a:agent {population_id:$pid})
|
||||
RETURN max(toFloat(a.fitness)) AS best
|
||||
""", pid=str(population_id))
|
||||
return float(rows[0]["best"] or 0.0) if rows else 0.0
|
||||
|
||||
|
||||
async def _calculate_energy_cost(population_id: str) -> float:
|
||||
"""
|
||||
Calculate the energy cost based on the fitness of agents and the number of neurons
|
||||
in the cortex associated with the given population ID.
|
||||
|
||||
Parameters:
|
||||
population_id (str): The ID of the population for which the energy cost needs to be calculated.
|
||||
|
||||
Returns:
|
||||
float: The calculated energy cost.
|
||||
"""
|
||||
rows = await _read_all("""
|
||||
MATCH (a:agent {population_id:$pid})-[:OWNS]->(cx:cortex)
|
||||
OPTIONAL MATCH (cx)-[:HAS]->(n:neuron)
|
||||
RETURN sum(coalesce(toFloat(a.fitness),0.0)) AS totE,
|
||||
count(n) AS totN
|
||||
""", pid=str(population_id))
|
||||
if not rows:
|
||||
return 0.0
|
||||
totE = float(rows[0]["totE"] or 0.0)
|
||||
totN = int(rows[0]["totN"] or 0)
|
||||
return (totE / totN) if totN > 0 else 0.0
|
||||
|
||||
|
||||
async def _construct_agent_summaries(agent_ids: Sequence[str]):
|
||||
"""
|
||||
Constructs summaries for the given list of agent IDs.
|
||||
|
||||
Parameters:
|
||||
agent_ids (Sequence[str]): A list of agent IDs for which summaries are to be constructed.
|
||||
|
||||
Return Type:
|
||||
List[Tuple[float, int, str]]: A list of tuples where each tuple contains the agent's fitness as a float,
|
||||
the count of neurons as an integer, and the agent ID as a string.
|
||||
"""
|
||||
if not agent_ids:
|
||||
return []
|
||||
rows = await _read_all("""
|
||||
UNWIND $ids AS aid
|
||||
MATCH (a:agent {id:aid})-[:OWNS]->(cx:cortex)
|
||||
OPTIONAL MATCH (cx)-[:HAS]->(n:neuron)
|
||||
RETURN aid AS id,
|
||||
toFloat(a.fitness) AS f,
|
||||
count(n) AS k
|
||||
""", ids=[str(x) for x in agent_ids])
|
||||
out: List[Tuple[float, int, str]] = []
|
||||
for r in rows:
|
||||
f = float(r["f"]) if r["f"] is not None else 0.0
|
||||
k = int(r["k"])
|
||||
out.append((f, k, str(r["id"])))
|
||||
return out
|
||||
|
||||
|
||||
async def _calculate_alotments(valid: List[Tuple[float, int, str]],
|
||||
neural_energy_cost: float
|
||||
):
|
||||
"""
|
||||
Calculate allotments based on fitness values and neural energy cost.
|
||||
|
||||
Parameters:
|
||||
valid (List[Tuple[float, int, str]]): A list of tuples containing fitness, total neurons, and agent ID.
|
||||
neural_energy_cost (float): The energy cost for neural activity.
|
||||
|
||||
Returns:
|
||||
Tuple[List[Tuple[float, float, int, str]], float]: A tuple containing a list of tuples with allotments,
|
||||
fitness values, total neurons, and agent IDs, and the total allotments for the new population.
|
||||
"""
|
||||
acc: List[Tuple[float, float, int, str]] = []
|
||||
new_pop_acc = 0.0
|
||||
for (fit, tn, aid) in valid:
|
||||
neural_alot = (fit / neural_energy_cost) if neural_energy_cost > 0 else 0.0
|
||||
mutant_alot = (neural_alot / max(tn, 1))
|
||||
new_pop_acc += mutant_alot
|
||||
acc.append((mutant_alot, fit, tn, aid))
|
||||
log.debug(f"NewPopAcc: {new_pop_acc:.4f}")
|
||||
return acc, new_pop_acc
|
||||
|
||||
|
||||
async def _extract_specie_agent_ids(specie_id: str) -> List[str]:
|
||||
"""
|
||||
Extracts the IDs of agents associated with a given specie.
|
||||
|
||||
:param specie_id: The ID of the specie for which to retrieve agent IDs
|
||||
:type specie_id: str
|
||||
:return: List of agent IDs associated with the specie
|
||||
:rtype: List[str]
|
||||
"""
|
||||
rows = await _read_all("""
|
||||
MATCH (:specie {id:$sid})-[:HAS]->(a:agent) RETURN a.id AS id
|
||||
""", sid=str(specie_id))
|
||||
return [str(r["id"]) for r in rows]
|
||||
|
||||
|
||||
async def _extract_specie_ids(population_id: str) -> List[str]:
|
||||
"""
|
||||
Extract specie IDs from Neo4j database for a given population ID.
|
||||
|
||||
:param population_id: str - The population ID for which to extract specie IDs.
|
||||
:return: List[str] - A list of specie IDs associated with the given population ID.
|
||||
|
||||
"""
|
||||
rows = await _read_all("""
|
||||
MATCH (s:specie {population_id:$pid}) RETURN s.id AS id ORDER BY id
|
||||
""", pid=str(population_id))
|
||||
return [str(r["id"]) for r in rows]
|
||||
|
||||
|
||||
async def _ensure_specie_node(specie_id: str, population_id: str, constraint_json: str):
|
||||
"""
|
||||
Ensure that the specie node exists. Used to keep the agent database coherent.
|
||||
|
||||
Parameters:
|
||||
specie_id (str): The ID of the specie to create.
|
||||
constraint_json (str): The JSON string of the constraints for the specie.
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
await _run("""
|
||||
MERGE (s:specie {id:$sid})
|
||||
SET s.population_id = $pid,
|
||||
s.constraint_json = $cjson
|
||||
""", sid=str(specie_id), pid=str(population_id), cjson=str(constraint_json))
|
||||
|
||||
|
||||
async def _extract_agent_ids(population_id: str) -> List[str]:
|
||||
"""
|
||||
Extracts the agent IDs associated with a given population ID.
|
||||
|
||||
Parameters:
|
||||
population_id (str): The ID of the population to extract agent IDs for.
|
||||
|
||||
Returns:
|
||||
List[str]: A list of agent IDs as strings extracted from the database based on the provided population ID.
|
||||
"""
|
||||
rows = await _read_all("MATCH (a:agent {population_id:$pid}) RETURN a.id AS id ORDER BY id",
|
||||
pid=str(population_id))
|
||||
return [str(r["id"]) for r in rows]
|
||||
|
||||
|
||||
async def _ensure_population_node(population_id: str) -> None:
|
||||
await _run("MERGE (:population {id:$pid})", pid=str(population_id))
|
||||
|
||||
|
||||
class PopulationMonitor:
|
||||
def __init__(self, op_mode: str, population_id: str, selection_algorithm: SelectionAlgorithm):
|
||||
self.state = MonitorState(op_mode, population_id, selection_algorithm)
|
||||
self.inbox: asyncio.Queue = asyncio.Queue()
|
||||
@@ -136,6 +297,7 @@ class PopulationMonitor:
|
||||
self._t0 = None
|
||||
self._best_so_far = float("-inf")
|
||||
self.train_time_sec = 30*60
|
||||
self._deadline_task = None
|
||||
|
||||
# logging file handles
|
||||
self._episodes_f = None
|
||||
@@ -144,6 +306,29 @@ class PopulationMonitor:
|
||||
@classmethod
|
||||
async def start(cls, op_mode: str, population_id: str,
|
||||
selection_algorithm: SelectionAlgorithm) -> "PopulationMonitor":
|
||||
"""
|
||||
Create and start a new population monitor instance.
|
||||
|
||||
This class method initializes a PopulationMonitor for the given
|
||||
population and selection algorithm, starts its asynchronous message
|
||||
processing loop, and prepares all logging and bookkeeping required for a
|
||||
training run.
|
||||
|
||||
Specifically, it:
|
||||
1. Instantiates the monitor with the specified operation mode, population
|
||||
identifier, and selection algorithm.
|
||||
2. Launches the internal actor-style run loop as an asyncio task.
|
||||
3. Registers an atexit hook to collect generation-level statistics for
|
||||
post-run analysis.
|
||||
4. Initializes timing, assigns a unique run identifier, and creates the
|
||||
corresponding output directory.
|
||||
5. Opens and initializes episode-level and progress-level CSV log files.
|
||||
6. Starts a deadline task to enforce the configured training time limit.
|
||||
7. Initializes and activates the first generation of the population.
|
||||
|
||||
The method returns the fully initialized and running PopulationMonitor
|
||||
instance, which can then be controlled via its public interface.
|
||||
"""
|
||||
self = cls(op_mode, population_id, selection_algorithm)
|
||||
self._task = asyncio.create_task(self._run(), name=f"PopulationMonitor-{population_id}")
|
||||
stats.register_atexit(population_id, lambda: list(self.state.rows))
|
||||
@@ -173,11 +358,48 @@ class PopulationMonitor:
|
||||
async def cast(self, msg: tuple) -> None:
|
||||
await self.inbox.put(msg)
|
||||
|
||||
async def stop(self, mode: Literal["normal", "shutdown"] = "normal") -> None:
|
||||
async def stop(self, mode: Literal["normal", "shutdown"] = "normal"):
|
||||
"""
|
||||
Request termination of the population monitor and await shutdown.
|
||||
|
||||
This method sends a stop command to the monitor's internal message loop
|
||||
and blocks until the monitor has completed its graceful shutdown
|
||||
procedure. The optional mode parameter indicates the reason for stopping
|
||||
(e.g. normal completion versus external shutdown) and is forwarded to
|
||||
the internal stop handler.
|
||||
|
||||
It provides a synchronous-style interface for external components to
|
||||
ensure that all agents are terminated, logs are flushed, and final
|
||||
results are persisted before control returns to the caller.
|
||||
"""
|
||||
await self.inbox.put(("stop", mode))
|
||||
await self._stopped_evt.wait()
|
||||
|
||||
async def _run(self) -> None:
|
||||
async def _run(self):
|
||||
"""
|
||||
Main asynchronous message-processing loop of the population monitor.
|
||||
|
||||
This method continuously consumes messages from the internal inbox and
|
||||
dispatches them to the appropriate handlers, coordinating the lifecycle
|
||||
of an evolutionary run. It implements an actor-style control loop with
|
||||
the following responsibilities:
|
||||
|
||||
1. Reacts to control messages:
|
||||
- "stop": triggers graceful shutdown and finalization of the run.
|
||||
- "pause": requests a pause after the current generation completes.
|
||||
- "continue": resumes execution and initializes a new generation
|
||||
after a pause.
|
||||
2. Handles evaluation-related events:
|
||||
- "episode_done": logs completion of a single evaluation episode.
|
||||
- "terminated": processes termination of an agent and updates
|
||||
generation-level state.
|
||||
3. Maintains correct sequencing of generations and respects the current
|
||||
operational mode (continue, pause, done).
|
||||
|
||||
The loop runs until a stop message is received or the task is cancelled.
|
||||
On exit, it signals completion via an internal stopped-event to allow
|
||||
other components to await full shutdown.
|
||||
"""
|
||||
try:
|
||||
while True:
|
||||
msg = await self.inbox.get()
|
||||
@@ -199,14 +421,24 @@ class PopulationMonitor:
|
||||
elif tag == "terminated":
|
||||
await self._handle_agent_terminated(*msg[1:])
|
||||
else:
|
||||
|
||||
pass
|
||||
finally:
|
||||
self._stopped_evt.set()
|
||||
|
||||
async def _init_generation(self) -> None:
|
||||
"""
|
||||
Initialize the agent generation process by extracting agent ids, setting initial values for the state object,
|
||||
and starting agents asynchronously. If any errors occur during the agent initialization process,
|
||||
the corresponding agent's fitness is set to negative infinity.
|
||||
|
||||
Parameters:
|
||||
- self: The reference to the current object instance.
|
||||
|
||||
Returns:
|
||||
- None
|
||||
"""
|
||||
s = self.state
|
||||
agent_ids = await self._extract_agent_ids(s.population_id)
|
||||
agent_ids = await _extract_agent_ids(s.population_id)
|
||||
s.agent_ids = agent_ids
|
||||
s.tot_agents = len(agent_ids)
|
||||
s.agents_left = 0
|
||||
@@ -227,7 +459,26 @@ class PopulationMonitor:
|
||||
log.info(f"*** Population monitor started: pop={s.population_id}, mode={s.op_mode}, "
|
||||
f"selection={s.selection_algorithm}, agents={s.tot_agents}")
|
||||
|
||||
async def _handle_stop(self, mode: str) -> None:
|
||||
async def _handle_stop(self, mode: str):
|
||||
"""
|
||||
Gracefully stop and finalize the population monitoring run.
|
||||
|
||||
This method is invoked when the monitoring loop receives a stop signal.
|
||||
It performs an orderly shutdown of the ongoing evolutionary run by:
|
||||
|
||||
1. Terminating all currently active agent handlers or actors.
|
||||
2. Flushing and closing episode-level and progress-level log files.
|
||||
3. Cancelling any active deadline or time-limit task.
|
||||
4. Finalizing the global best-so-far fitness value based on the current
|
||||
population state.
|
||||
5. Writing a run summary to disk, including identifiers, training
|
||||
duration, generation count, accumulated evaluation statistics, and
|
||||
final best-so-far fitness.
|
||||
6. Clearing internal file handles and logging the shutdown event.
|
||||
|
||||
The method ensures that partial results are safely persisted and that
|
||||
all asynchronous resources are released before the run terminates.
|
||||
"""
|
||||
s = self.state
|
||||
|
||||
for (_aid, h) in list(s.active):
|
||||
@@ -246,20 +497,76 @@ class PopulationMonitor:
|
||||
f.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
if getattr(self, "_deadline_task", None):
|
||||
self._deadline_task.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
best = await _best_fitness_in_population(self.state.population_id)
|
||||
self._best_so_far = max(self._best_so_far, float(best))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
summary = {
|
||||
"run_id": self.run_id,
|
||||
"population_id": self.state.population_id,
|
||||
"train_time_sec": self.train_time_sec,
|
||||
"gens": self.state.pop_gen,
|
||||
"eval_acc": self.state.eval_acc,
|
||||
"best_so_far": self._best_so_far,
|
||||
"op_tag": self.state.op_tag,
|
||||
}
|
||||
with open(f"runs/{self.run_id}/summary.json", "w") as f:
|
||||
json.dump(summary, f, indent=2)
|
||||
|
||||
self._episodes_f = None
|
||||
self._progress_f = None
|
||||
log.info(f"*** Population_Monitor:{s.population_id} shutdown. op_tag={s.op_tag}, op_mode={s.op_mode}")
|
||||
|
||||
await neo4j.close()
|
||||
async def _handle_episode_done(self, agent_id: str, ep_return: float, eval_idx: int):
|
||||
"""
|
||||
Handle completion of a single evaluation episode for an agent.
|
||||
|
||||
async def _handle_episode_done(self, agent_id: str, ep_return: float, eval_idx: int) -> None:
|
||||
This method is called whenever an agent finishes one evaluation episode
|
||||
within the current generation. It records episode-level information for
|
||||
later analysis and monitoring but does not alter population-level state
|
||||
or control flow.
|
||||
|
||||
Specifically, it:
|
||||
1. Computes the elapsed wall-clock time since the start of the run.
|
||||
2. Logs the episode result (time, generation index, agent identifier,
|
||||
evaluation index, and episode return) to the episode-level log file,
|
||||
if enabled.
|
||||
"""
|
||||
s = self.state
|
||||
t_sec = asyncio.get_running_loop().time() - (self._t0 or 0.0)
|
||||
if self._episodes_f is not None:
|
||||
self._episodes_f.write(f"{t_sec:.6f},{s.pop_gen+1},{agent_id},{eval_idx},{ep_return:.10f}\n")
|
||||
self._episodes_f.write(f"{t_sec:.6f},{s.pop_gen},{agent_id},{eval_idx},{ep_return:.10f}\n")
|
||||
|
||||
async def _handle_agent_terminated(self, agent_id: str, fitness: float, agent_eval: int, agent_cycle: int,
|
||||
agent_time: int) -> None:
|
||||
agent_time: int):
|
||||
"""
|
||||
Handle termination of a single agent evaluation.
|
||||
|
||||
This method is invoked when an agent finishes its evaluation within the
|
||||
current generation. It performs the following actions:
|
||||
|
||||
1. Accumulates per-agent evaluation statistics (evaluation count, cycle
|
||||
count, and execution time) into generation-level counters.
|
||||
2. Decrements the number of remaining active agents in the generation.
|
||||
3. Persists the agent's final fitness value to the underlying genotype
|
||||
storage.
|
||||
4. Removes the terminated agent from the list of currently active agents.
|
||||
5. Logs progress information, including how many agents are still active.
|
||||
6. Triggers generation finalization once all agents in the generation
|
||||
have completed their evaluations.
|
||||
|
||||
The method is fully asynchronous and is typically called by the actor
|
||||
supervision or monitoring component when an agent signals termination.
|
||||
"""
|
||||
log.info(f"agent terminated: , {agent_id}, {fitness}, {agent_eval}, {agent_cycle}, {agent_time}")
|
||||
s = self.state
|
||||
|
||||
@@ -280,6 +587,31 @@ class PopulationMonitor:
|
||||
await self._generation_finished()
|
||||
|
||||
async def _generation_finished(self) -> None:
|
||||
"""
|
||||
Handle the end of a population generation.
|
||||
|
||||
This method is called once all agents of the current generation have
|
||||
completed their evaluations. It performs the following steps:
|
||||
|
||||
1. Mutates and selects the next generation according to the configured
|
||||
selection algorithm and species size limit.
|
||||
2. Increments the generation counter and logs aggregated population
|
||||
statistics (best, average, standard deviation of fitness).
|
||||
3. Updates time-based metrics, including elapsed wall-clock time,
|
||||
normalized training time, and the global best-so-far fitness.
|
||||
4. Writes a progress entry to the progress log (CSV-style) and appends
|
||||
a detailed statistics record to the in-memory history.
|
||||
5. Signals the end of the generation via an asyncio.Event to unblock
|
||||
dependent tasks.
|
||||
6. Checks termination conditions, including generation limit,
|
||||
evaluation limit, fitness goal, and wall-clock time limit.
|
||||
7. Depending on the current operation tag and termination conditions,
|
||||
either stops the run, pauses execution, or initializes the next
|
||||
generation.
|
||||
|
||||
The method is fully asynchronous and intended to be called from the
|
||||
population monitoring loop that coordinates evolutionary training.
|
||||
"""
|
||||
s = self.state
|
||||
await self._mutate_population(s.population_id, SPECIE_SIZE_LIMIT, s.selection_algorithm)
|
||||
s.pop_gen += 1
|
||||
@@ -326,7 +658,7 @@ class PopulationMonitor:
|
||||
f"({elapsed:.1f}s >= {self.train_time_sec}s), stopping run"
|
||||
)
|
||||
|
||||
best = await self._best_fitness_in_population(s.population_id)
|
||||
best = await _best_fitness_in_population(s.population_id)
|
||||
end_condition = (s.pop_gen >= GENERATION_LIMIT) or (s.eval_acc >= EVALUATIONS_LIMIT) or (best > FITNESS_GOAL) or time_limit_reached
|
||||
if s.pop_gen >= GENERATION_LIMIT:
|
||||
log.info(f"reached generation limit {GENERATION_LIMIT}, stopping")
|
||||
@@ -345,45 +677,63 @@ class PopulationMonitor:
|
||||
|
||||
await self._init_generation()
|
||||
|
||||
async def _ensure_population_node(self, population_id: str) -> None:
|
||||
await _run("MERGE (:population {id:$pid})", pid=str(population_id))
|
||||
|
||||
async def _ensure_specie_node(self, specie_id: str, population_id: str, constraint_json: str) -> None:
|
||||
await _run("""
|
||||
MERGE (s:specie {id:$sid})
|
||||
SET s.population_id = $pid,
|
||||
s.constraint_json = $cjson
|
||||
""", sid=str(specie_id), pid=str(population_id), cjson=str(constraint_json))
|
||||
|
||||
async def _extract_agent_ids(self, population_id: str) -> List[str]:
|
||||
rows = await _read_all("MATCH (a:agent {population_id:$pid}) RETURN a.id AS id ORDER BY id",
|
||||
pid=str(population_id))
|
||||
return [str(r["id"]) for r in rows]
|
||||
|
||||
async def _extract_specie_ids(self, population_id: str) -> List[str]:
|
||||
rows = await _read_all("""
|
||||
MATCH (s:specie {population_id:$pid}) RETURN s.id AS id ORDER BY id
|
||||
""", pid=str(population_id))
|
||||
return [str(r["id"]) for r in rows]
|
||||
|
||||
async def _extract_specie_agent_ids(self, specie_id: str) -> List[str]:
|
||||
rows = await _read_all("""
|
||||
MATCH (:specie {id:$sid})-[:HAS]->(a:agent) RETURN a.id AS id
|
||||
""", sid=str(specie_id))
|
||||
return [str(r["id"]) for r in rows]
|
||||
|
||||
async def _mutate_population(self, population_id: str, keep_tot: int,
|
||||
selection_algorithm: SelectionAlgorithm) -> None:
|
||||
energy_cost = await self._calculate_energy_cost(population_id)
|
||||
specie_ids = await self._extract_specie_ids(population_id)
|
||||
selection_algorithm: SelectionAlgorithm):
|
||||
"""
|
||||
Apply evolutionary mutation and selection to an entire population.
|
||||
|
||||
This method orchestrates the mutation step at the population level at the
|
||||
end of a generation. It first computes shared contextual information,
|
||||
such as the current energy cost of the population, and then iterates over
|
||||
all species belonging to the population.
|
||||
|
||||
For each species, it delegates the actual selection and mutation process
|
||||
to the species-level mutation routine, ensuring that the total number of
|
||||
individuals retained respects the configured population size limit and
|
||||
the chosen selection algorithm.
|
||||
|
||||
The method is asynchronous and intended to be invoked as part of the
|
||||
generation finalization phase of the evolutionary loop.
|
||||
"""
|
||||
energy_cost = await _calculate_energy_cost(population_id)
|
||||
specie_ids = await _extract_specie_ids(population_id)
|
||||
for sid in specie_ids:
|
||||
await self._mutate_specie(sid, keep_tot, energy_cost, selection_algorithm)
|
||||
|
||||
async def _mutate_specie(self, specie_id: str, population_limit: int, neural_energy_cost: float,
|
||||
selection_algorithm: SelectionAlgorithm) -> None:
|
||||
agent_ids = await self._extract_specie_agent_ids(specie_id)
|
||||
selection_algorithm: SelectionAlgorithm):
|
||||
"""
|
||||
Mutate and repopulate a single species according to the chosen selection strategy.
|
||||
|
||||
summaries = await self._construct_agent_summaries(agent_ids)
|
||||
This method performs the end-of-generation evolutionary step for one
|
||||
species ("specie") within a population. It:
|
||||
|
||||
1. Loads all agents belonging to the species and constructs fitness
|
||||
summaries, sorting agents by fitness in descending order.
|
||||
2. Selects survivors and removes non-survivors from both the genotype
|
||||
store and the species membership relationship.
|
||||
3. Creates new offspring agents to refill the species up to the target
|
||||
population limit, using one of two selection algorithms:
|
||||
- "competition": keeps a fraction of the best agents (SURVIVAL_PERCENTAGE),
|
||||
ranks them by an efficiency-weighted score (fitness adjusted by
|
||||
network size/complexity), deletes the rest, and produces offspring
|
||||
via the competition routine.
|
||||
- "top3": keeps only the top 3 agents, deletes all others, and produces
|
||||
the required number of offspring from these champions.
|
||||
4. Computes aggregate fitness statistics for the species (mean, standard
|
||||
deviation, min, max) and updates the species node with these values as
|
||||
well as the list of champion agent IDs.
|
||||
5. Updates the species' innovation factor based on whether the current
|
||||
best fitness exceeds the stored innovation threshold.
|
||||
6. Refreshes fingerprints for newly created agents to keep derived
|
||||
metadata consistent.
|
||||
|
||||
The method is asynchronous and is intended to be called from the
|
||||
population-level mutation step after a generation has completed.
|
||||
"""
|
||||
agent_ids = await _extract_specie_agent_ids(specie_id)
|
||||
|
||||
summaries = await _construct_agent_summaries(agent_ids)
|
||||
summaries.sort(key=lambda t: t[0], reverse=True)
|
||||
|
||||
if not summaries:
|
||||
@@ -452,31 +802,63 @@ class PopulationMonitor:
|
||||
|
||||
async def _competition(self, valid: List[Tuple[float, int, str]], population_limit: int,
|
||||
neural_energy_cost: float, specie_id: str) -> List[str]:
|
||||
alot, est = await self._calculate_alotments(valid, neural_energy_cost)
|
||||
"""
|
||||
Perform competition-based reproduction for a species.
|
||||
|
||||
This method implements the competition selection strategy for a single
|
||||
species. Given a set of valid (surviving) agents, it:
|
||||
|
||||
1. Computes reproduction allotments for each agent based on fitness and
|
||||
neural energy cost, yielding a total estimated population size.
|
||||
2. Derives a normalization factor to scale these allotments so that the
|
||||
resulting number of survivors and offspring respects the configured
|
||||
population size limit.
|
||||
3. Delegates to the survivor-gathering routine to retain parent agents
|
||||
and generate the appropriate number of mutant offspring.
|
||||
|
||||
The method returns a list of agent identifiers representing the new
|
||||
species population after competition-based selection and mutation. It is
|
||||
asynchronous and intended to be used during the species-level mutation
|
||||
phase of the evolutionary cycle.
|
||||
"""
|
||||
alot, est = await _calculate_alotments(valid, neural_energy_cost)
|
||||
normalizer = (est / population_limit) if population_limit > 0 else 1.0
|
||||
log.debug(f"Population size normalizer: {normalizer:.4f}")
|
||||
return await self._gather_survivors(alot, normalizer, specie_id)
|
||||
|
||||
async def _calculate_alotments(self, valid: List[Tuple[float, int, str]], neural_energy_cost: float
|
||||
) -> Tuple[List[Tuple[float, float, int, str]], float]:
|
||||
acc: List[Tuple[float, float, int, str]] = []
|
||||
new_pop_acc = 0.0
|
||||
for (fit, tn, aid) in valid:
|
||||
neural_alot = (fit / neural_energy_cost) if neural_energy_cost > 0 else 0.0
|
||||
mutant_alot = (neural_alot / max(tn, 1))
|
||||
new_pop_acc += mutant_alot
|
||||
acc.append((mutant_alot, fit, tn, aid))
|
||||
log.debug(f"NewPopAcc: {new_pop_acc:.4f}")
|
||||
return acc, new_pop_acc
|
||||
|
||||
async def _gather_survivors(self, alot: List[Tuple[float, float, int, str]], normalizer: float, specie_id: str) -> \
|
||||
List[str]:
|
||||
"""
|
||||
Collect survivors and generate offspring for a species based on normalized allotments.
|
||||
|
||||
This method takes a list of agents with precomputed allotment scores and
|
||||
determines how many times each agent should survive or reproduce into
|
||||
the next generation. For each agent, it:
|
||||
|
||||
1. Computes a reproduction count by normalizing the agent's allotment
|
||||
value against a global normalizer.
|
||||
2. Enforces a hard safety constraint to ensure that each listed agent
|
||||
contributes at least one survivor to the next generation.
|
||||
3. Ensures that the surviving agent remains linked to the species.
|
||||
4. Creates the required number of mutant offspring clones for the agent
|
||||
to satisfy its allotted reproduction count.
|
||||
5. Removes agents that would otherwise receive zero allotment from the
|
||||
species and deletes their genotype representation.
|
||||
|
||||
The method returns a list of agent identifiers representing the newly
|
||||
formed species population, including both surviving parents and newly
|
||||
created offspring. It is asynchronous and intended to be used as part of
|
||||
the species-level reproduction process.
|
||||
"""
|
||||
new_ids: List[str] = []
|
||||
for (ma, fit, tn, aid) in alot:
|
||||
count = int(round(ma / normalizer)) if normalizer > 0 else 0
|
||||
# hard safety: keep at least one survivor
|
||||
if count <= 0:
|
||||
count = 1
|
||||
log.info(f"Agent {aid}: normalized allotment = {count}")
|
||||
# TODO: this is redundant!
|
||||
if count >= 1:
|
||||
|
||||
await _run("""
|
||||
MATCH (s:specie {id:$sid}), (a:agent {id:$aid})
|
||||
MERGE (s)-[:HAS]->(a)
|
||||
@@ -497,6 +879,26 @@ class PopulationMonitor:
|
||||
return new_ids
|
||||
|
||||
async def _create_mutant_offspring(self, parent_id: str, specie_id: str) -> str:
|
||||
"""
|
||||
Create a mutated offspring from a parent agent.
|
||||
|
||||
This method clones an existing agent to create a new offspring and
|
||||
integrates it into the evolutionary population. It performs the
|
||||
following steps:
|
||||
|
||||
1. Generates a new unique agent identifier and clones the parent agent's
|
||||
genotype into the offspring.
|
||||
2. Inherits and sets species and population identifiers for the cloned
|
||||
agent and clears its fitness value to mark it as unevaluated.
|
||||
3. Establishes the species membership relationship between the offspring
|
||||
agent and the corresponding species.
|
||||
4. Applies a single mutation step to the offspring's genotype to
|
||||
introduce variation.
|
||||
|
||||
The method returns the identifier of the newly created mutant offspring.
|
||||
It is asynchronous and intended to be used during the reproduction phase
|
||||
of species-level evolution.
|
||||
"""
|
||||
clone_id = _new_id()
|
||||
|
||||
await clone_agent(parent_id, clone_id)
|
||||
@@ -520,17 +922,32 @@ class PopulationMonitor:
|
||||
await self._mutate_one_step(clone_id)
|
||||
return clone_id
|
||||
|
||||
async def _mutate_one_step(self, agent_id: str) -> None:
|
||||
async def _mutate_one_step(self, agent_id: str):
|
||||
"""
|
||||
Apply a single random mutation step to an agent.
|
||||
|
||||
This method selects one mutation operator at random from the available
|
||||
set of genotype mutation operations (e.g. weight mutation, bias
|
||||
insertion/removal, structural link or neuron changes) and applies it to
|
||||
the specified agent.
|
||||
|
||||
If the selected mutation operation fails, the method falls back to a
|
||||
simple weight-mutation step as a safety measure. Any further failure is
|
||||
silently ignored to ensure that the evolutionary process can continue
|
||||
without interrupting the run.
|
||||
|
||||
The method is asynchronous and intended to introduce variation during
|
||||
offspring creation.
|
||||
"""
|
||||
ops = [
|
||||
self.mutator.mutate_weights,
|
||||
self.mutator.add_bias,
|
||||
self.mutator.remove_bias,
|
||||
self.mutator.add_inlink,
|
||||
self.mutator.add_outlink,
|
||||
self.mutator.add_neuron,
|
||||
self.mutator.outsplice,
|
||||
self.mutator.add_actuator,
|
||||
self.mutator.mutate_weights_tx,
|
||||
self.mutator.add_bias_tx,
|
||||
self.mutator.remove_bias_tx,
|
||||
self.mutator.add_inlink_tx,
|
||||
self.mutator.add_outlink_tx,
|
||||
self.mutator.add_neuron_tx,
|
||||
self.mutator.outsplice_tx,
|
||||
self.mutator.add_actuator_tx,
|
||||
]
|
||||
op = random.choice(ops)
|
||||
try:
|
||||
@@ -538,65 +955,57 @@ class PopulationMonitor:
|
||||
except Exception:
|
||||
|
||||
try:
|
||||
await self.mutator.mutate_weights(agent_id)
|
||||
await self.mutator.mutate_weights_tx(agent_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _top3(self, valid_ids: List[str], offspring_needed: int, specie_id: str) -> List[str]:
|
||||
"""
|
||||
Generate offspring using the top-3 selection strategy.
|
||||
|
||||
This method implements the "top3" reproduction strategy for a species.
|
||||
It retains the three best-performing agents unchanged and fills the
|
||||
remaining population slots by repeatedly selecting one of these top
|
||||
agents at random and creating a mutated offspring from it.
|
||||
|
||||
The method returns a list of agent identifiers representing the new
|
||||
species population, consisting of the original top agents and their
|
||||
offspring. It is asynchronous and intended to be used during the
|
||||
species-level mutation phase of the evolutionary cycle.
|
||||
"""
|
||||
new_ids = list(valid_ids)
|
||||
for _ in range(offspring_needed):
|
||||
parent = random.choice(valid_ids)
|
||||
cid = await self._create_mutant_offspring(parent, specie_id)
|
||||
new_ids.append(cid)
|
||||
|
||||
return new_ids
|
||||
|
||||
async def _construct_agent_summaries(self, agent_ids: Sequence[str]) -> List[Tuple[float, int, str]]:
|
||||
if not agent_ids:
|
||||
return []
|
||||
rows = await _read_all("""
|
||||
UNWIND $ids AS aid
|
||||
MATCH (a:agent {id:aid})-[:OWNS]->(cx:cortex)
|
||||
OPTIONAL MATCH (cx)-[:HAS]->(n:neuron)
|
||||
RETURN aid AS id,
|
||||
toFloat(a.fitness) AS f,
|
||||
count(n) AS k
|
||||
""", ids=[str(x) for x in agent_ids])
|
||||
out: List[Tuple[float, int, str]] = []
|
||||
for r in rows:
|
||||
f = float(r["f"]) if r["f"] is not None else 0.0
|
||||
k = int(r["k"])
|
||||
out.append((f, k, str(r["id"])))
|
||||
return out
|
||||
|
||||
async def _calculate_energy_cost(self, population_id: str) -> float:
|
||||
rows = await _read_all("""
|
||||
MATCH (a:agent {population_id:$pid})-[:OWNS]->(cx:cortex)
|
||||
OPTIONAL MATCH (cx)-[:HAS]->(n:neuron)
|
||||
RETURN sum(coalesce(toFloat(a.fitness),0.0)) AS totE,
|
||||
count(n) AS totN
|
||||
""", pid=str(population_id))
|
||||
if not rows:
|
||||
return 0.0
|
||||
totE = float(rows[0]["totE"] or 0.0)
|
||||
totN = int(rows[0]["totN"] or 0)
|
||||
return (totE / totN) if totN > 0 else 0.0
|
||||
|
||||
async def _best_fitness_in_population(self, population_id: str) -> float:
|
||||
rows = await _read_all("""
|
||||
MATCH (a:agent {population_id:$pid})
|
||||
RETURN max(toFloat(a.fitness)) AS best
|
||||
""", pid=str(population_id))
|
||||
return float(rows[0]["best"] or 0.0) if rows else 0.0
|
||||
|
||||
|
||||
async def init_population(params: Tuple[str, List[dict], str, SelectionAlgorithm]) -> PopulationMonitor:
|
||||
"""
|
||||
params = (Population_Id, Specie_Constraints, OpMode, Selection_Algorithm)
|
||||
- erzeugt Population/Spezies-Knoten
|
||||
- konstruiert Agents (über dein construct_agent)
|
||||
- verknüpft Spezies->Agent, setzt agent.population_id
|
||||
- startet den Monitor
|
||||
Initialize a new evolutionary population and start its monitor.
|
||||
|
||||
This function creates a fresh population in the genotype store based on
|
||||
the provided species constraints and launches a PopulationMonitor to
|
||||
manage its evolutionary process. It performs the following steps:
|
||||
|
||||
1. Deletes any existing population with the given identifier to ensure a
|
||||
clean initialization.
|
||||
2. Creates a new population node in the datastore.
|
||||
3. For each specified species constraint:
|
||||
- Creates a new species node associated with the population.
|
||||
- Stores the species constraint configuration and initializes its
|
||||
innovation factor.
|
||||
- Creates an initial set of agents for the species, constructing their
|
||||
genotypes according to the given constraints.
|
||||
- Assigns population and species identifiers to each agent, clears
|
||||
fitness values, and establishes species membership relationships.
|
||||
4. Starts a PopulationMonitor for the initialized population, using the
|
||||
specified operation mode and selection algorithm.
|
||||
|
||||
The function returns the running PopulationMonitor instance, which
|
||||
coordinates evaluation, mutation, and logging for the newly created
|
||||
population.
|
||||
"""
|
||||
population_id, specie_constraints, op_mode, selection_algorithm = params
|
||||
|
||||
@@ -639,10 +1048,39 @@ async def init_population(params: Tuple[str, List[dict], str, SelectionAlgorithm
|
||||
|
||||
async def continue_(op_mode: str, selection_algorithm: SelectionAlgorithm,
|
||||
population_id: str = INIT_POPULATION_ID) -> PopulationMonitor:
|
||||
"""
|
||||
Resume or start a population monitor for an existing population.
|
||||
|
||||
This convenience function starts a PopulationMonitor for the specified
|
||||
population using the given operation mode and selection algorithm,
|
||||
without reinitializing or modifying the underlying population structure.
|
||||
|
||||
It is intended to continue an existing evolutionary run or to attach a
|
||||
new monitor to a pre-existing population state. The function returns the
|
||||
running PopulationMonitor instance, which immediately begins coordinating
|
||||
evaluation and evolution according to the provided parameters.
|
||||
"""
|
||||
return await PopulationMonitor.start(op_mode, population_id, selection_algorithm)
|
||||
|
||||
|
||||
async def delete_population(population_id: str) -> None:
|
||||
async def delete_population(population_id: str):
|
||||
"""
|
||||
Delete a population and all associated evolutionary entities.
|
||||
|
||||
This function removes a population and all data linked to it from the
|
||||
genotype store. It performs a cascading deletion that includes:
|
||||
|
||||
1. The population node itself.
|
||||
2. All species belonging to the population.
|
||||
3. All agents within those species.
|
||||
4. All cortical structures owned by the agents, including neurons,
|
||||
sensors, and actuators.
|
||||
|
||||
All relationships are detached prior to deletion to ensure referential
|
||||
integrity. The operation is destructive and intended to be used during
|
||||
population reinitialization or cleanup before starting a new evolutionary
|
||||
run.
|
||||
"""
|
||||
await _run("""
|
||||
MATCH (p:population {id:$pid})
|
||||
OPTIONAL MATCH (s:specie {population_id:$pid})
|
||||
@@ -652,7 +1090,3 @@ async def delete_population(population_id: str) -> None:
|
||||
OPTIONAL MATCH (cx)-[:HAS]->(act:actuator)
|
||||
DETACH DELETE p, s, a, cx, n, sen, act
|
||||
""", pid=str(population_id))
|
||||
|
||||
|
||||
async def test() -> PopulationMonitor:
|
||||
return await init_population((INIT_POPULATION_ID, INIT_CONSTRAINTS, INIT_OP_MODE, INIT_SELECTION_ALGO))
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
import asyncio
|
||||
import os
|
||||
from typing import Any, Dict, List, Tuple, Optional
|
||||
|
||||
from mathema.core import morphology
|
||||
from mathema.genotype.neo4j.genotype import construct, print_genotype
|
||||
from mathema.core.exoself import Exoself
|
||||
|
||||
|
||||
class Trainer:
|
||||
def __init__(
|
||||
self,
|
||||
morphology_spec=morphology,
|
||||
hidden_layer_densities: List[int] = None,
|
||||
*,
|
||||
max_attempts: int = 5,
|
||||
eval_limit: float = float("inf"),
|
||||
fitness_target: float = float("inf"),
|
||||
experimental_file: Optional[str] = "experimental.json",
|
||||
best_file: Optional[str] = "best.json",
|
||||
exoself_steps_per_eval: int = 0,
|
||||
):
|
||||
self.morphology_spec = morphology_spec
|
||||
self.hds = hidden_layer_densities or []
|
||||
self.max_attempts = max_attempts
|
||||
self.eval_limit = eval_limit
|
||||
self.fitness_target = fitness_target
|
||||
self.experimental_file = experimental_file
|
||||
self.best_file = best_file
|
||||
self.exoself_steps_per_eval = exoself_steps_per_eval
|
||||
|
||||
self.best_fitness = float("-inf")
|
||||
self.best_genotype: Optional[Dict[str, Any]] = None
|
||||
|
||||
self.eval_acc = 0
|
||||
self.cycle_acc = 0
|
||||
self.time_acc = 0.0
|
||||
|
||||
async def _run_one_attempt(self) -> Tuple[float, int, int, float]:
|
||||
print("constructing genotype...")
|
||||
geno = construct(
|
||||
self.morphology_spec,
|
||||
self.hds,
|
||||
file_name=self.experimental_file, # <-- schreibt Startnetz nach experimental.json
|
||||
add_bias=True
|
||||
)
|
||||
fitness, evals, cycles, elapsed = await self._evaluate_with_exoself(geno)
|
||||
return fitness, evals, cycles, elapsed
|
||||
|
||||
async def _evaluate_with_exoself(self, genotype: Dict[str, Any]) -> Tuple[float, int, int, float]:
|
||||
print("creating exoself...")
|
||||
ex = Exoself(genotype, file_name=self.experimental_file)
|
||||
best_fitness, evals, cycles, elapsed = await ex.train_until_stop()
|
||||
return best_fitness, evals, cycles, elapsed
|
||||
|
||||
async def go(self):
|
||||
attempt = 1
|
||||
while True:
|
||||
print(".........")
|
||||
print("current attempt: ", attempt)
|
||||
print(".........")
|
||||
|
||||
if attempt > self.max_attempts or self.eval_acc >= self.eval_limit or self.best_fitness >= self.fitness_target:
|
||||
# Abschlussausgabe wie im Buch
|
||||
if self.best_file and os.path.exists(self.best_file):
|
||||
print_genotype(self.best_file)
|
||||
print(
|
||||
f" Morphology: {getattr(self.morphology_spec, '__name__', str(self.morphology_spec))} | "
|
||||
f"Best Fitness: {self.best_fitness} | EvalAcc: {self.eval_acc}"
|
||||
)
|
||||
return {
|
||||
"best_fitness": self.best_fitness,
|
||||
"eval_acc": self.eval_acc,
|
||||
"cycle_acc": self.cycle_acc,
|
||||
"time_acc": self.time_acc,
|
||||
"best_file": self.best_file,
|
||||
}
|
||||
|
||||
print("RUN ONE ATTEMPT!")
|
||||
fitness, evals, cycles, elapsed = await self._run_one_attempt()
|
||||
|
||||
print("update akkus...")
|
||||
|
||||
self.eval_acc += evals
|
||||
self.cycle_acc += cycles
|
||||
self.time_acc += elapsed
|
||||
|
||||
# Besser als bisher?
|
||||
if fitness > self.best_fitness:
|
||||
self.best_fitness = fitness
|
||||
if self.best_file and self.experimental_file and os.path.exists(self.experimental_file):
|
||||
os.replace(self.experimental_file, self.best_file)
|
||||
attempt = 1
|
||||
else:
|
||||
attempt += 1
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
trainer = Trainer(
|
||||
morphology_spec=morphology,
|
||||
hidden_layer_densities=[2],
|
||||
max_attempts=200,
|
||||
eval_limit=float("inf"),
|
||||
fitness_target=99.9,
|
||||
experimental_file="experimental.json",
|
||||
best_file="best.json",
|
||||
exoself_steps_per_eval=0,
|
||||
)
|
||||
|
||||
asyncio.run(trainer.go())
|
||||
Reference in New Issue
Block a user