Neha Singh
Initial commit: OpenENV Shopping Agent Environment
d6e8cd0
Raw
History Blame Contribute Delete
14.6 kB
"""
RL Shopping Agent — Learns to pick products matching the user's personality.
Uses a simple weight-based policy:
- Maintains feature weights that represent what matters to this user
- Starts with random/uniform weights (no knowledge)
- After each episode, updates weights using the personality grader reward
- Over episodes, converges on the correct feature priorities
The personality_grader acts as the reward function:
- High personality alignment → positive reward → reinforce those feature weights
- Low alignment → negative reward → adjust weights away from that pick
Agent Strategy (per episode):
1. Score all products using current weights × features
2. Pick the top-scoring product
3. Get reward from personality grader
4. Update weights via gradient-like update
5. If score < threshold, try again with updated weights (within same episode)
Persistence:
- Weights are saved to memory/agent_weights.json
- Learning history is saved to memory/learning_log.jsonl
"""
import json
import math
import random
import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
MEMORY_DIR = Path(__file__).parent / "memory"
WEIGHTS_FILE = MEMORY_DIR / "agent_weights.json"
LEARNING_LOG = MEMORY_DIR / "learning_log.jsonl"
# Feature dimensions the agent learns weights for
FEATURE_KEYS = [
"price_norm", # normalized price (0=cheapest, 1=most expensive)
"rating_norm", # normalized rating (0-1)
"reviews_norm", # normalized review count (0-1)
"brand_premium", # 1 if premium brand, 0.5 if mid, 0 if budget/unknown
"refundable", # 1 if refundable, 0 if not
"seller_trusted", # 1 if official/store, 0 if unknown
"review_volume", # log-scaled review count
"value_ratio", # rating / price_norm — value for money
]
DEFAULT_WEIGHTS = {k: 0.0 for k in FEATURE_KEYS} # Start with no knowledge
# Learning hyperparameters
LEARNING_RATE = 0.15
EXPLORATION_RATE_INITIAL = 0.6 # Start with lots of exploration
EXPLORATION_DECAY = 0.85 # Decay per episode
EXPLORATION_MIN = 0.05
SUCCESS_THRESHOLD = 0.75 # Personality score needed to "succeed"
MAX_ATTEMPTS_PER_EPISODE = 8
class RLShoppingAgent:
"""Simple RL agent that learns to pick personality-aligned products."""
def __init__(self):
self.weights = dict(DEFAULT_WEIGHTS)
self.epsilon = EXPLORATION_RATE_INITIAL
self.episode_count = 0
self.total_reward = 0.0
self.success_count = 0
self._load_weights()
# ---- Weight persistence ----
def _load_weights(self):
"""Load saved weights from disk."""
if WEIGHTS_FILE.exists():
try:
data = json.loads(WEIGHTS_FILE.read_text(encoding="utf-8"))
self.weights = data.get("weights", dict(DEFAULT_WEIGHTS))
self.epsilon = data.get("epsilon", EXPLORATION_RATE_INITIAL)
self.episode_count = data.get("episode_count", 0)
self.total_reward = data.get("total_reward", 0.0)
self.success_count = data.get("success_count", 0)
except Exception as e:
print(f"[rl_agent] Error loading weights: {e}")
def _save_weights(self):
"""Save weights to disk."""
try:
data = {
"weights": self.weights,
"epsilon": round(self.epsilon, 4),
"episode_count": self.episode_count,
"total_reward": round(self.total_reward, 4),
"success_count": self.success_count,
"last_updated": datetime.datetime.now().isoformat(),
}
WEIGHTS_FILE.write_text(json.dumps(data, indent=2), encoding="utf-8")
except Exception as e:
print(f"[rl_agent] Error saving weights: {e}")
def reset_weights(self):
"""Reset all learned weights (fresh start)."""
self.weights = dict(DEFAULT_WEIGHTS)
self.epsilon = EXPLORATION_RATE_INITIAL
self.episode_count = 0
self.total_reward = 0.0
self.success_count = 0
self._save_weights()
# ---- Feature extraction ----
def _extract_features(self, product: Dict, all_products: List[Dict]) -> Dict[str, float]:
"""Extract normalized features from a product."""
prices = [p.get("price", 0) for p in all_products]
reviews = [p.get("reviews", 0) for p in all_products]
min_p, max_p = min(prices), max(prices)
min_r, max_r = min(reviews), max(reviews)
price = product.get("price", 0)
rating = product.get("rating", 3.0)
review_count = product.get("reviews", 0)
brand = product.get("brand", "")
seller = product.get("seller", "")
refundable = product.get("refundable", True)
# Normalize price (inverted: lower price = higher score for value shoppers)
price_norm = 1.0 - ((price - min_p) / max(max_p - min_p, 1.0))
rating_norm = (rating - 1.0) / 4.0 # 1-5 → 0-1
reviews_norm = (review_count - min_r) / max(max_r - min_r, 1) if max_r > min_r else 0.5
# Brand tier
brand_lower = brand.lower()
seller_lower = seller.lower()
brand_premium = 0.5 # default mid
if any(w in seller_lower for w in ["official", "store"]):
brand_premium = 0.8
if any(w in seller_lower for w in ["unknown", "quick", "flash", "random", "budget bazaar"]):
brand_premium = 0.1
# Premium brand detection
if review_count >= 3000 and rating >= 4.5:
brand_premium = 1.0
elif review_count < 30 and rating < 3.0:
brand_premium = 0.0
seller_trusted = 1.0 if "official" in seller_lower or "store" in seller_lower else 0.0
review_volume = math.log(max(review_count, 1)) / 10.0 # log scale, ~0-1
value_ratio = rating_norm / max(1.0 - price_norm + 0.1, 0.1) # higher = better value
return {
"price_norm": round(price_norm, 4),
"rating_norm": round(rating_norm, 4),
"reviews_norm": round(reviews_norm, 4),
"brand_premium": round(brand_premium, 4),
"refundable": 1.0 if refundable else 0.0,
"seller_trusted": round(seller_trusted, 4),
"review_volume": round(min(review_volume, 1.0), 4),
"value_ratio": round(min(value_ratio, 2.0) / 2.0, 4), # normalize to 0-1
}
def _score_product(self, features: Dict[str, float]) -> float:
"""Score a product using current weights."""
score = 0.0
for key in FEATURE_KEYS:
score += self.weights.get(key, 0.0) * features.get(key, 0.0)
return score
# ---- Core RL methods ----
def pick_product(
self, products: List[Dict], excluded_ids: set = None
) -> Tuple[Dict, Dict[str, float], str]:
"""
Pick a product using epsilon-greedy policy.
Returns: (product, features, strategy_used)
"""
excluded_ids = excluded_ids or set()
available = [p for p in products if p["id"] not in excluded_ids]
if not available:
return None, {}, "no_products"
# Epsilon-greedy: explore or exploit
if random.random() < self.epsilon:
# EXPLORATION: pick randomly
chosen = random.choice(available)
features = self._extract_features(chosen, products)
return chosen, features, "exploration"
else:
# EXPLOITATION: pick best according to learned weights
best_product = None
best_score = float("-inf")
best_features = {}
for p in available:
feats = self._extract_features(p, products)
score = self._score_product(feats)
if score > best_score:
best_score = score
best_product = p
best_features = feats
return best_product, best_features, "exploitation"
def update_weights(
self, features: Dict[str, float], reward: float, personality_score: float
):
"""
Update weights based on reward signal.
If reward is positive: increase weights for the features present in the product
If negative: decrease them
"""
# Simple policy gradient update
for key in FEATURE_KEYS:
feat_val = features.get(key, 0.0)
if feat_val > 0:
# Update proportional to feature value and reward
gradient = reward * feat_val
self.weights[key] += LEARNING_RATE * gradient
# Decay exploration rate
self.epsilon = max(EXPLORATION_MIN, self.epsilon * EXPLORATION_DECAY)
self._save_weights()
def run_episode(
self,
products: List[Dict],
scored_products: List[Dict],
) -> List[Dict[str, Any]]:
"""
Run a full autonomous learning episode.
The agent picks products, the personality_grader scores them,
and the agent learns from the reward signal.
Returns list of attempt records.
"""
self.episode_count += 1
attempts = []
excluded_ids = set()
episode_reward = 0.0
# Get the ideal product (top personality score) for comparison
ideal = scored_products[0] if scored_products else None
ideal_score = ideal["personality_score"] if ideal else 0.5
for attempt_num in range(1, MAX_ATTEMPTS_PER_EPISODE + 1):
# Agent picks
product, features, strategy = self.pick_product(products, excluded_ids)
if product is None:
break
# Get personality score for this product (environment reward)
personality_score = 0.0
for sp in scored_products:
if sp["product"]["id"] == product["id"]:
personality_score = sp["personality_score"]
break
# Determine if this is a "success" (close to ideal)
alignment_ratio = personality_score / max(ideal_score, 0.01)
is_success = alignment_ratio >= 0.95 # within 5% of ideal
# Calculate reward
if is_success:
# Reward depends on which attempt
reward_map = {1: 1.0, 2: 0.7, 3: 0.4}
reward = reward_map.get(attempt_num, 0.2)
else:
# Penalty scaled by how far off
reward = -0.2 - (1.0 - alignment_ratio) * 0.3
reward = round(reward, 4)
episode_reward += reward
# Agent learns from this experience
self.update_weights(features, reward, personality_score)
# Build reasoning
reasoning = self._build_auto_reasoning(
product, features, strategy, personality_score,
alignment_ratio, attempt_num, is_success,
)
attempt = {
"attempt": attempt_num,
"product_id": product["id"],
"product_name": product["name"],
"product": product,
"features": features,
"strategy": strategy,
"personality_score": round(personality_score, 4),
"alignment_ratio": round(alignment_ratio, 4),
"reward": reward,
"is_success": is_success,
"reasoning": reasoning,
"weights_snapshot": dict(self.weights),
"epsilon": round(self.epsilon, 4),
}
attempts.append(attempt)
if is_success:
self.success_count += 1
break
# Exclude this product for next attempt
excluded_ids.add(product["id"])
self.total_reward += episode_reward
self._save_weights()
self._log_episode(attempts, episode_reward)
return attempts
def _build_auto_reasoning(
self, product, features, strategy, p_score,
alignment, attempt, success,
) -> str:
"""Build reasoning text explaining the agent's decision."""
lines = []
if strategy == "exploration":
lines.append(f"🎲 Exploring (ε={self.epsilon:.0%}) — picked randomly to discover new patterns.")
else:
lines.append("🧠 Exploiting learned weights — picking what I've learned works best.")
# Top features that influenced the pick
weighted = [(k, self.weights[k] * features.get(k, 0)) for k in FEATURE_KEYS]
weighted.sort(key=lambda x: abs(x[1]), reverse=True)
top = [w for w in weighted[:3] if abs(w[1]) > 0.01]
if top:
influences = ", ".join(
f"{k.replace('_', ' ')} ({v:+.2f})" for k, v in top
)
lines.append(f"Top influences: {influences}")
lines.append(f"Personality match: {p_score:.0%} (alignment: {alignment:.0%})")
if success:
lines.append(f"✅ SUCCESS! Close to ideal product on attempt #{attempt}.")
else:
lines.append(f"❌ Not quite right. Updating weights and trying again...")
return " | ".join(lines)
def _log_episode(self, attempts: List[Dict], total_reward: float):
"""Log the episode to learning_log.jsonl."""
try:
entry = {
"timestamp": datetime.datetime.now().isoformat(),
"episode": self.episode_count,
"attempts_count": len(attempts),
"total_reward": round(total_reward, 4),
"success": any(a["is_success"] for a in attempts),
"final_epsilon": round(self.epsilon, 4),
"products_tried": [a["product_name"] for a in attempts],
}
with open(LEARNING_LOG, "a", encoding="utf-8") as f:
f.write(json.dumps(entry) + "\n")
except Exception as e:
print(f"[rl_agent] Error logging episode: {e}")
def get_stats(self) -> Dict[str, Any]:
"""Get agent learning statistics."""
return {
"episode_count": self.episode_count,
"success_count": self.success_count,
"success_rate": round(
self.success_count / max(self.episode_count, 1), 4
),
"total_reward": round(self.total_reward, 4),
"epsilon": round(self.epsilon, 4),
"weights": {k: round(v, 4) for k, v in self.weights.items()},
}