Spaces:
Sleeping
Sleeping
Download src/qlearning_agent.py from vivekharry/3sacrowd: direct link, hf CLI and curl.
- Browser
- Download file 11.3 kB
-
https://huggingface.co/spaces/vivekharry/3sacrowd/resolve/main/src/qlearning_agent.py
- Command line
-
hf download hf://spaces/vivekharry/3sacrowd/src/qlearning_agent.py
-
curl -L -o qlearning_agent.py https://huggingface.co/spaces/vivekharry/3sacrowd/resolve/main/src/qlearning_agent.py
11.3 kB
| """ | |
| Tabular Q-Learning Agent for ChaosLab. | |
| A classic reinforcement learning agent that maintains a Q-table | |
| (Python dict) mapping (state_hash, action) β expected reward. | |
| NO neural networks. NO GPU. Pure Python + numpy. | |
| The observation (4096-dim ASCII vector) is hashed into a compact | |
| state key using a combination of keyword detection and structural | |
| fingerprinting, keeping the table size manageable. | |
| Implements `.predict(obs)` matching the stable-baselines3 API | |
| so it plugs into the model registry seamlessly. | |
| Training: | |
| from src.qlearning_agent import QLearningAgent, train_qlearning | |
| agent = train_qlearning(scenario="full_incident", episodes=500) | |
| agent.save("models/qlearning_model.json") | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import hashlib | |
| import numpy as np | |
| import os | |
| import random | |
| from typing import Any, Dict, List, Optional, Tuple | |
| from collections import defaultdict | |
| from .rl_env import ACTION_CATALOG, NUM_ACTIONS | |
| # ====================================================================== | |
| # STATE HASHING β compress 4096-dim observation into a table key | |
| # ====================================================================== | |
| # Keywords to detect in the observation text for state fingerprinting | |
| _STATE_KEYWORDS = [ | |
| "DEAD", "dead", "running", "RUNNING", | |
| "error", "500", "502", | |
| "Failed password", "brute", | |
| "permission", "executable", | |
| "disk", "full", "90%", "95%", "100%", | |
| "memory", "low", | |
| "cron", "FAILED", | |
| "nginx", "postgres", "app", | |
| "cleanup.sh", "backup.sh", | |
| "Score: 0.0", "Score: 0.2", "Score: 0.5", "Score: 0.8", "Score: 1.0", | |
| "/var/log", "/home/user", | |
| ] | |
| def _decode_obs(obs: np.ndarray) -> str: | |
| """Decode ASCII-encoded observation back to text.""" | |
| chars = [] | |
| for code in obs: | |
| c = int(code) | |
| if c == 0: | |
| break | |
| if 0 < c < 128: | |
| chars.append(chr(c)) | |
| return "".join(chars) | |
| def _hash_state(obs: np.ndarray) -> str: | |
| """ | |
| Hash a 4096-dim ASCII observation into a compact state key. | |
| Strategy: detect which keywords are present in the text and | |
| create a binary fingerprint. This maps the huge observation | |
| space into a manageable number of distinct states (~2^30 max, | |
| but in practice much fewer). | |
| """ | |
| text = _decode_obs(obs) | |
| text_lower = text.lower() | |
| # Binary fingerprint: which keywords are present? | |
| bits = [] | |
| for kw in _STATE_KEYWORDS: | |
| bits.append("1" if kw.lower() in text_lower else "0") | |
| fingerprint = "".join(bits) | |
| # Also include a rough hash of the first 200 chars for | |
| # distinguishing structurally different observations | |
| prefix_hash = hashlib.md5(text[:200].encode()).hexdigest()[:6] | |
| return f"{fingerprint}_{prefix_hash}" | |
| # ====================================================================== | |
| # TABULAR Q-LEARNING AGENT | |
| # ====================================================================== | |
| class QLearningAgent: | |
| """ | |
| Tabular Q-Learning with epsilon-greedy exploration. | |
| Q-table is a Python dict: { (state_hash, action_idx): q_value } | |
| Hyperparameters: | |
| alpha: Learning rate (how fast Q-values update) | |
| gamma: Discount factor (importance of future rewards) | |
| epsilon: Exploration rate (probability of random action) | |
| """ | |
| def __init__( | |
| self, | |
| alpha: float = 0.1, | |
| gamma: float = 0.95, | |
| epsilon: float = 0.1, | |
| ): | |
| self.alpha = alpha | |
| self.gamma = gamma | |
| self.epsilon = epsilon | |
| # Q-table: (state_hash, action) β q_value | |
| self.q_table: Dict[str, float] = defaultdict(float) | |
| # Tracking | |
| self._prev_state: Optional[str] = None | |
| self._prev_action: Optional[int] = None | |
| self._episode_count: int = 0 | |
| self._total_updates: int = 0 | |
| def predict( | |
| self, | |
| obs: np.ndarray, | |
| deterministic: bool = True, | |
| ) -> Tuple[np.ndarray, None]: | |
| """ | |
| Pick an action given observation (SB3-compatible interface). | |
| When deterministic=True (inference), always pick the best action. | |
| When deterministic=False (training), use epsilon-greedy. | |
| """ | |
| state = _hash_state(obs) | |
| if not deterministic and random.random() < self.epsilon: | |
| # Explore: random action | |
| action = random.randint(0, NUM_ACTIONS - 1) | |
| else: | |
| # Exploit: pick action with highest Q-value | |
| action = self._best_action(state) | |
| return np.array(action), None | |
| def update( | |
| self, | |
| state_obs: np.ndarray, | |
| action: int, | |
| reward: float, | |
| next_obs: np.ndarray, | |
| done: bool, | |
| ): | |
| """ | |
| Q-Learning update rule: | |
| Q(s, a) β Q(s, a) + Ξ± * [r + Ξ³ * max_a' Q(s', a') - Q(s, a)] | |
| """ | |
| state = _hash_state(state_obs) | |
| next_state = _hash_state(next_obs) | |
| old_q = self.q_table[f"{state}_{action}"] | |
| if done: | |
| target = reward | |
| else: | |
| # max Q-value for next state across all actions | |
| best_next = max( | |
| self.q_table.get(f"{next_state}_{a}", 0.0) | |
| for a in range(NUM_ACTIONS) | |
| ) | |
| target = reward + self.gamma * best_next | |
| # Q-Learning update | |
| self.q_table[f"{state}_{action}"] = old_q + self.alpha * (target - old_q) | |
| self._total_updates += 1 | |
| def _best_action(self, state: str) -> int: | |
| """Return action with highest Q-value for given state.""" | |
| best_val = float("-inf") | |
| best_actions = [0] | |
| for a in range(NUM_ACTIONS): | |
| q = self.q_table.get(f"{state}_{a}", 0.0) | |
| if q > best_val: | |
| best_val = q | |
| best_actions = [a] | |
| elif q == best_val: | |
| best_actions.append(a) | |
| # Break ties randomly | |
| return random.choice(best_actions) | |
| def save(self, path: str): | |
| """Save Q-table + metadata to JSON file.""" | |
| data = { | |
| "q_table": dict(self.q_table), | |
| "alpha": self.alpha, | |
| "gamma": self.gamma, | |
| "epsilon": self.epsilon, | |
| "episode_count": self._episode_count, | |
| "total_updates": self._total_updates, | |
| "num_states": len(set( | |
| k.rsplit("_", 1)[0] for k in self.q_table.keys() | |
| )), | |
| "table_size": len(self.q_table), | |
| } | |
| with open(path, "w") as f: | |
| json.dump(data, f) | |
| def load(cls, path: str) -> "QLearningAgent": | |
| """Load a trained Q-table from JSON file.""" | |
| with open(path, "r") as f: | |
| data = json.load(f) | |
| agent = cls( | |
| alpha=data.get("alpha", 0.1), | |
| gamma=data.get("gamma", 0.95), | |
| epsilon=data.get("epsilon", 0.1), | |
| ) | |
| agent.q_table = defaultdict(float, data["q_table"]) | |
| agent._episode_count = data.get("episode_count", 0) | |
| agent._total_updates = data.get("total_updates", 0) | |
| return agent | |
| def stats(self) -> Dict[str, Any]: | |
| """Return training statistics.""" | |
| unique_states = len(set( | |
| "_".join(k.split("_")[:-1]) for k in self.q_table.keys() | |
| )) | |
| return { | |
| "table_entries": len(self.q_table), | |
| "unique_states": unique_states, | |
| "episodes_trained": self._episode_count, | |
| "total_updates": self._total_updates, | |
| } | |
| def get_reasoning(obs: np.ndarray) -> str: | |
| """Return human-readable explanation of what the agent sees.""" | |
| text = _decode_obs(obs) | |
| state = _hash_state(obs) | |
| detections = [] | |
| checks = [ | |
| ("dead", "β Dead service"), | |
| ("error", "π Error in logs"), | |
| ("500", "π΄ HTTP 500"), | |
| ("502", "π΄ HTTP 502"), | |
| ("failed password", "π SSH brute-force"), | |
| ("permission", "π Permission issue"), | |
| ("90%", "πΎ Disk >90%"), | |
| ("cron", "β° Cron issue"), | |
| ("memory", "π§ Memory pressure"), | |
| ] | |
| for keyword, label in checks: | |
| if keyword in text.lower(): | |
| detections.append(label) | |
| prefix = f"[Q-Table lookup: state={state[:20]}...]" | |
| if detections: | |
| return f"{prefix} {' | '.join(detections)}" | |
| return f"{prefix} π Scanning..." | |
| # ====================================================================== | |
| # TRAINING FUNCTION | |
| # ====================================================================== | |
| def train_qlearning( | |
| scenario: str = "full_incident", | |
| episodes: int = 200, | |
| alpha: float = 0.15, | |
| gamma: float = 0.95, | |
| epsilon_start: float = 1.0, | |
| epsilon_end: float = 0.05, | |
| epsilon_decay: float = 0.995, | |
| verbose: bool = True, | |
| ) -> QLearningAgent: | |
| """ | |
| Train a Q-Learning agent on the ChaosLab environment. | |
| Uses epsilon-greedy with decay: starts exploring randomly | |
| and gradually shifts to exploiting learned Q-values. | |
| Args: | |
| scenario: Which scenario to train on | |
| episodes: Number of training episodes | |
| alpha: Learning rate | |
| gamma: Discount factor | |
| epsilon_start: Initial exploration rate | |
| epsilon_end: Minimum exploration rate | |
| epsilon_decay: Multiplicative decay per episode | |
| Returns: | |
| Trained QLearningAgent | |
| """ | |
| import gymnasium as gym | |
| import src.rl_env # registers ChaosLab-v0 | |
| env = gym.make("ChaosLab-v0", scenario=scenario) | |
| agent = QLearningAgent(alpha=alpha, gamma=gamma, epsilon=epsilon_start) | |
| total_rewards = [] | |
| best_score = 0.0 | |
| for ep in range(episodes): | |
| obs, info = env.reset() | |
| episode_reward = 0.0 | |
| done = False | |
| while not done: | |
| # Epsilon-greedy action selection | |
| action_arr, _ = agent.predict(obs, deterministic=False) | |
| action = int(action_arr) | |
| next_obs, reward, terminated, truncated, info = env.step(action) | |
| done = terminated or truncated | |
| # Q-Learning update | |
| agent.update(obs, action, reward, next_obs, done) | |
| obs = next_obs | |
| episode_reward += reward | |
| agent._episode_count += 1 | |
| total_rewards.append(episode_reward) | |
| # Decay epsilon | |
| agent.epsilon = max(epsilon_end, agent.epsilon * epsilon_decay) | |
| score = info.get("task_score", 0.0) | |
| if score > best_score: | |
| best_score = score | |
| if verbose and (ep + 1) % 25 == 0: | |
| avg_reward = sum(total_rewards[-25:]) / min(25, len(total_rewards)) | |
| stats = agent.stats() | |
| print( | |
| f" Episode {ep+1:>4d}/{episodes} | " | |
| f"Avg Reward: {avg_reward:>7.3f} | " | |
| f"Best Score: {best_score:.2f} | " | |
| f"Ξ΅: {agent.epsilon:.3f} | " | |
| f"Q-entries: {stats['table_entries']:,} | " | |
| f"States: {stats['unique_states']:,}" | |
| ) | |
| env.close() | |
| if verbose: | |
| stats = agent.stats() | |
| print(f"\n Training complete!") | |
| print(f" Q-table: {stats['table_entries']:,} entries across {stats['unique_states']:,} states") | |
| print(f" Best score achieved: {best_score:.2f}") | |
| return agent | |