Spaces:
Paused
Paused
| """ | |
| Core ML logic for CLIP-based Image Retrieval with Progressive Pipeline Steering. | |
| Stripped of Flask — designed to be imported by app.py (Gradio). | |
| """ | |
| import os | |
| import json | |
| from pathlib import Path | |
| from typing import List, Dict, Tuple | |
| import torch | |
| import numpy as np | |
| import open_clip | |
| from PIL import Image | |
| # Try importing Groq (optional — falls back to hardcoded attributes) | |
| try: | |
| from groq import Groq | |
| except ImportError: | |
| Groq = None | |
| # ============================================================================= | |
| # CONFIGURATION | |
| # ============================================================================= | |
| MODEL_NAME = "ViT-B-16" | |
| DEVICE = "cuda" if torch.cuda.is_available() else "cpu" | |
| # Dataset configurations — paths relative to repo root | |
| DATASETS = { | |
| "flickr": { | |
| "name": "Flickr Images", | |
| "images_dir": Path("Images"), | |
| "embeddings_file": Path("image_embeddings.npz"), | |
| "description": "Flickr image dataset", | |
| }, | |
| "stanford_dogs": { | |
| "name": "Stanford Dogs", | |
| "images_dir": Path("stanford_dogs_subset"), | |
| "embeddings_file": Path("stanford_dogs_embeddings.npz"), | |
| "description": "Stanford Dogs dataset", | |
| }, | |
| "celeba": { | |
| "name": "CelebA Faces", | |
| "images_dir": Path("celeba_subset"), | |
| "embeddings_file": Path("celeba_embeddings.npz"), | |
| "description": "CelebA celebrity faces", | |
| }, | |
| } | |
| # ============================================================================= | |
| # GLOBAL STATE | |
| # ============================================================================= | |
| _current_dataset: str = "flickr" | |
| _model = None | |
| _preprocess = None # OpenAI CLIP image preprocessing transform | |
| _tokenizer = None # OpenAI CLIP text tokenizer | |
| _image_embeddings = None # L2-normalised (for cosine retrieval) | |
| _image_embeddings_raw = None # Raw (for SAE encode — not normalised) | |
| _image_names = None | |
| # Groq client (initialised lazily) | |
| _groq_client = None | |
| _groq_checked = False | |
| # ============================================================================= | |
| # HELPERS | |
| # ============================================================================= | |
| def _get_groq_client(): | |
| """Lazily initialise the Groq client from the GROQ_API_KEY env var / secret.""" | |
| global _groq_client, _groq_checked | |
| if _groq_checked: | |
| return _groq_client | |
| _groq_checked = True | |
| api_key = os.getenv("GROQ_API_KEY") | |
| if api_key and Groq is not None: | |
| try: | |
| _groq_client = Groq(api_key=api_key) | |
| print("✅ Groq client initialised") | |
| except Exception as exc: | |
| print(f"⚠️ Could not init Groq: {exc}") | |
| else: | |
| reason = "GROQ_API_KEY not set" if not api_key else "groq package not installed" | |
| print(f"⚠️ {reason} — using fallback attributes") | |
| return _groq_client | |
| # ============================================================================= | |
| # MODEL + EMBEDDINGS | |
| # ============================================================================= | |
| def load_clip_model(): | |
| """Load OpenAI CLIP ViT-B/16 model via open_clip (cached after first call). | |
| Uses pretrained='openai' to load the exact same weights as the original | |
| OpenAI CLIP package (same CDN, identical embeddings). | |
| """ | |
| global _model, _preprocess, _tokenizer | |
| if _model is None or _preprocess is None: | |
| print(f"🔄 Loading OpenAI CLIP model: {MODEL_NAME}") | |
| _model, _, _preprocess = open_clip.create_model_and_transforms( | |
| MODEL_NAME, pretrained="openai", device=DEVICE, | |
| ) | |
| _tokenizer = open_clip.get_tokenizer(MODEL_NAME) | |
| _model.eval() | |
| for p in _model.parameters(): | |
| p.requires_grad = False | |
| print(f"✅ CLIP loaded on {DEVICE}") | |
| return _model, _preprocess | |
| def get_text_embedding(text: str, normalize: bool = True) -> np.ndarray: | |
| """Get a single CLIP text embedding using open_clip (OpenAI weights).""" | |
| load_clip_model() # ensure model + tokenizer are loaded | |
| tokens = _tokenizer([text]).to(DEVICE) | |
| with torch.no_grad(): | |
| feats = _model.encode_text(tokens).float() | |
| if normalize: | |
| feats = feats / feats.norm(dim=-1, keepdim=True) | |
| return feats[0].cpu().numpy() | |
| def load_or_compute_embeddings() -> Tuple[np.ndarray, List[str]]: | |
| """Load precomputed embeddings for the current dataset. | |
| Stores both L2-normalised (for cosine retrieval) and raw embeddings | |
| (for SAE encode, which expects un-normalised OpenAI CLIP outputs). | |
| """ | |
| global _image_embeddings, _image_embeddings_raw, _image_names | |
| if _image_embeddings is not None and _image_names is not None: | |
| return _image_embeddings, _image_names | |
| cfg = DATASETS[_current_dataset] | |
| emb_path = cfg["embeddings_file"] | |
| if emb_path.exists(): | |
| print(f"📂 Loading embeddings from {emb_path}") | |
| data = np.load(emb_path, allow_pickle=True) | |
| raw = data["embeddings"].astype(np.float32) | |
| _image_embeddings_raw = raw | |
| # L2-normalise for cosine retrieval | |
| norms = np.linalg.norm(raw, axis=-1, keepdims=True) + 1e-8 | |
| _image_embeddings = raw / norms | |
| _image_names = data["image_names"].tolist() | |
| print(f"✅ Loaded {len(_image_embeddings)} embeddings") | |
| return _image_embeddings, _image_names | |
| # Fallback: compute on the fly (slow on CPU) | |
| images_dir = cfg["images_dir"] | |
| if not images_dir.exists(): | |
| raise FileNotFoundError( | |
| f"Neither embeddings ({emb_path}) nor images ({images_dir}) found. " | |
| "Upload at least the .npz embeddings file." | |
| ) | |
| print("🔄 Computing embeddings from scratch (this may take a while on CPU)…") | |
| model, preprocess = load_clip_model() | |
| image_files = sorted(images_dir.glob("*.jpg"))[:500] | |
| embeddings_list, valid_names = [], [] | |
| batch_size = 16 | |
| for i in range(0, len(image_files), batch_size): | |
| batch_files = image_files[i : i + batch_size] | |
| batch_tensors, batch_names = [], [] | |
| for p in batch_files: | |
| try: | |
| img = preprocess(Image.open(p).convert("RGB")) | |
| batch_tensors.append(img) | |
| batch_names.append(p.name) | |
| except Exception as exc: | |
| print(f"⚠️ Skipping {p.name}: {exc}") | |
| if batch_tensors: | |
| image_input = torch.stack(batch_tensors).to(DEVICE) | |
| with torch.no_grad(): | |
| embs = model.encode_image(image_input).float() | |
| embs = embs / embs.norm(dim=-1, keepdim=True) | |
| embeddings_list.extend(embs.cpu().numpy()) | |
| valid_names.extend(batch_names) | |
| _image_embeddings = np.array(embeddings_list) | |
| _image_names = valid_names | |
| np.savez_compressed(emb_path, embeddings=_image_embeddings, image_names=np.array(_image_names)) | |
| print(f"✅ Computed & saved {len(_image_embeddings)} embeddings") | |
| return _image_embeddings, _image_names | |
| # ============================================================================= | |
| # DATASET MANAGEMENT | |
| # ============================================================================= | |
| def get_available_datasets() -> Dict[str, Dict]: | |
| """Return info about each dataset (name, availability).""" | |
| info = {} | |
| for key, cfg in DATASETS.items(): | |
| info[key] = { | |
| "name": cfg["name"], | |
| "description": cfg["description"], | |
| "has_embeddings": cfg["embeddings_file"].exists(), | |
| "has_images": cfg["images_dir"].exists(), | |
| } | |
| return info | |
| def get_current_dataset() -> str: | |
| return _current_dataset | |
| def switch_dataset(dataset_key: str): | |
| """Switch active dataset and reload embeddings.""" | |
| global _current_dataset, _image_embeddings, _image_embeddings_raw, _image_names | |
| if dataset_key == _current_dataset and _image_embeddings is not None: | |
| return # already active | |
| if dataset_key not in DATASETS: | |
| raise ValueError(f"Unknown dataset: {dataset_key}") | |
| _current_dataset = dataset_key | |
| _image_embeddings = None | |
| _image_embeddings_raw = None | |
| _image_names = None | |
| load_or_compute_embeddings() | |
| def get_raw_embeddings() -> np.ndarray: | |
| """Return raw (un-normalised) image embeddings for SAE encode.""" | |
| if _image_embeddings_raw is not None: | |
| return _image_embeddings_raw | |
| # If raw not yet loaded, load_or_compute_embeddings will populate it | |
| load_or_compute_embeddings() | |
| return _image_embeddings_raw | |
| def get_image_path(image_name: str) -> Path: | |
| """Resolve an image name to its full path under the current dataset.""" | |
| return DATASETS[_current_dataset]["images_dir"] / image_name | |
| # ============================================================================= | |
| # LLM FEEDBACK | |
| # ============================================================================= | |
| _FALLBACK_ATTRIBUTES: Dict[str, Dict] = { | |
| # ── Stanford Dogs (7) ── | |
| "a golden retriever": { | |
| "positive": ["golden retriever dog", "light golden fur", "medium to long wavy coat", "floppy ears", "broad head", "friendly face"], | |
| "negative": ["short fur dog", "dark colored fur", "pointed upright ears", "small dog breed", "cat"], | |
| }, | |
| "dog on the beach": { | |
| "positive": ["dog on sand", "ocean waves", "beach scenery", "wet fur", "running on beach", "sunny outdoor"], | |
| "negative": ["indoor", "snow", "city street", "forest", "furniture"], | |
| }, | |
| "dog looking guilty": { | |
| "positive": ["sad", "droopy", "ashamed", "looking down", "submissive", "avoiding eye contact", "head down"], | |
| "negative": ["happy", "playful", "energetic", "excited", "proud", "confident"], | |
| }, | |
| "friendly looking dog": { | |
| "positive": ["happy", "playful", "gentle", "cute", "adorable", "wagging tail", "soft eyes"], | |
| "negative": ["aggressive", "scary", "angry", "mean", "threatening", "snarling"], | |
| }, | |
| "aggressive looking dog": { | |
| "positive": ["snarling", "baring teeth", "aggressive stance", "tense body", "raised hackles", "intense stare"], | |
| "negative": ["gentle", "cute", "relaxed", "playful", "sleeping", "wagging tail"], | |
| }, | |
| "nervous looking dog": { | |
| "positive": ["anxious", "scared", "worried", "trembling", "wide eyes", "ears back", "cowering", "tail tucked"], | |
| "negative": ["confident", "relaxed", "happy", "calm", "bold", "playful"], | |
| }, | |
| "hyper active dog": { | |
| "positive": ["running", "jumping", "playing", "energetic", "fast movement", "mouth open", "excited"], | |
| "negative": ["sleeping", "lying down", "calm", "still", "resting", "lazy"], | |
| }, | |
| # ── Flickr (7) ── | |
| "a person riding a bicycle": { | |
| "positive": ["cyclist", "bicycle", "riding bike", "pedaling", "wheels", "helmet"], | |
| "negative": ["walking", "car", "sitting on bench", "standing still", "motorcycle"], | |
| }, | |
| "a dog playing": { | |
| "positive": ["playful dog", "running", "fetching", "jumping", "toy in mouth", "energetic", "outdoor play"], | |
| "negative": ["sleeping", "sitting still", "calm", "resting", "sad"], | |
| }, | |
| "an exciting action scene": { | |
| "positive": ["sports", "jumping", "running", "movement", "dynamic action", "fast motion", "athletic"], | |
| "negative": ["standing", "sitting", "calm", "still", "resting", "portrait"], | |
| }, | |
| "a joyful moment": { | |
| "positive": ["smiling", "laughing", "celebrating", "happy faces", "hugging", "arms raised", "bright colors"], | |
| "negative": ["sad", "crying", "angry", "alone", "dark", "serious face"], | |
| }, | |
| "a kid having fun": { | |
| "positive": ["child playing", "laughing kid", "outdoor play", "toys", "smiling child", "running", "playground"], | |
| "negative": ["adult", "serious", "crying", "sitting still", "elderly", "office"], | |
| }, | |
| "peaceful scene": { | |
| "positive": ["calm water", "sunset", "nature", "quiet", "serene landscape", "soft light", "still"], | |
| "negative": ["crowded", "noisy", "urban", "traffic", "construction", "chaotic"], | |
| }, | |
| "a photo with motion": { | |
| "positive": ["motion blur", "running", "moving fast", "dynamic", "action", "speed", "blurred background"], | |
| "negative": ["still", "static", "portrait", "posed", "standing", "sharp focus"], | |
| }, | |
| # ── CelebA (8) ── | |
| "wearing eyeglasses": { | |
| "positive": ["eyeglasses", "spectacles", "glasses frames", "lenses on face", "reading glasses"], | |
| "negative": ["no glasses", "bare face", "sunglasses", "contact lenses"], | |
| }, | |
| "a person smiling": { | |
| "positive": ["smiling", "teeth showing", "happy expression", "grin", "cheerful face", "bright eyes"], | |
| "negative": ["frowning", "serious face", "sad", "neutral expression", "angry"], | |
| }, | |
| "looking guilty": { | |
| "positive": ["worried", "nervous", "serious", "sad", "secretive", "uncomfortable", "looking away", "avoiding eye contact"], | |
| "negative": ["smiling", "happy", "confident", "laughing", "relaxed", "proud"], | |
| }, | |
| "looking happy": { | |
| "positive": ["smiling", "laughing", "joyful", "bright eyes", "cheerful", "grinning", "radiant"], | |
| "negative": ["sad", "frowning", "crying", "angry", "tired", "bored"], | |
| }, | |
| "looking sad": { | |
| "positive": ["frowning", "tearful", "downcast eyes", "drooping mouth", "somber", "melancholy", "looking down"], | |
| "negative": ["smiling", "happy", "laughing", "excited", "cheerful", "energetic"], | |
| }, | |
| "looking suspicious": { | |
| "positive": ["narrowed eyes", "side glance", "raised eyebrow", "squinting", "tense jaw", "furrowed brow"], | |
| "negative": ["smiling", "relaxed", "open face", "friendly", "trusting", "wide eyes"], | |
| }, | |
| "looking tired": { | |
| "positive": ["droopy eyes", "yawning", "dark circles", "half closed eyes", "slouching", "exhausted"], | |
| "negative": ["alert", "energetic", "wide awake", "bright eyes", "smiling", "active"], | |
| }, | |
| "looking confident": { | |
| "positive": ["upright posture", "direct eye contact", "chin up", "strong stance", "composed", "assertive"], | |
| "negative": ["slouching", "looking down", "nervous", "fidgeting", "shy", "uncertain"], | |
| }, | |
| } | |
| def _fallback_feedback(query: str) -> Dict: | |
| """Query-specific fallback when Groq is unavailable or rate-limited.""" | |
| key = query.strip().lower() | |
| for fb_key, fb_val in _FALLBACK_ATTRIBUTES.items(): | |
| if fb_key.lower() == key: | |
| return { | |
| "positive": [{"attribute": a, "weight": 0.8, "rationale": "fallback"} | |
| for a in fb_val["positive"]], | |
| "negative": [{"attribute": a, "weight": 0.6, "rationale": "fallback"} | |
| for a in fb_val["negative"]], | |
| "alpha": 0.4, | |
| "beta": 0.4, | |
| } | |
| # Generic fallback for unknown queries | |
| return { | |
| "positive": [ | |
| {"attribute": query, "weight": 0.9, "rationale": "Original query"}, | |
| ], | |
| "negative": [], | |
| "alpha": 0.4, | |
| "beta": 0.4, | |
| } | |
| def generate_feedback_with_weights(query: str) -> Dict: | |
| """Generate per-attribute weights from an LLM (or fallback).""" | |
| client = _get_groq_client() | |
| if client is None: | |
| return _fallback_feedback(query) | |
| prompt = f"""You are an expert at decomposing image-search queries into visually grounded CLIP steering attributes. | |
| CONTEXT | |
| - We steer a CLIP ViT-B/16 query embedding by adding positive attribute embeddings and subtracting negative ones. | |
| - Attributes must be OBSERVABLE VISUAL properties — things you can literally SEE in a photograph. | |
| - Each attribute is 1-5 words. No abstract concepts. No full sentences. | |
| CRITICAL RULES FOR GOOD ATTRIBUTES | |
| - For EMOTIONS or SUBJECTIVE states: describe the BODY LANGUAGE, not the emotion word. | |
| BAD: "guilty expression" (CLIP doesn't understand abstract emotions well) | |
| GOOD: "looking down", "avoiding eye contact", "head lowered", "droopy ears" | |
| - For PHYSICAL descriptions: use concrete visual details. | |
| BAD: "beautiful dog" (vague, subjective) | |
| GOOD: "light golden fur", "floppy ears", "broad head", "medium to long wavy coat" | |
| - For SCENES: describe observable elements. | |
| BAD: "exciting scene" (abstract) | |
| GOOD: "jumping", "running", "movement", "sports" | |
| - NEGATIVES must be the visual OPPOSITE or a common CLIP confusion. | |
| They should suppress what CLIP tends to retrieve incorrectly for this query. | |
| FEW-SHOT EXAMPLES (from human experts): | |
| Query: "a dog looking guilty" | |
| positive: ["sad", "droopy", "ashamed", "looking down", "submissive", "avoiding eye contact", "head down"] | |
| negative: ["happy", "playful", "energetic", "excited", "proud", "confident"] | |
| Query: "a nervous-looking dog" | |
| positive: ["anxious", "scared", "worried", "trembling", "wide eyes", "ears back"] | |
| negative: ["confident", "relaxed", "happy", "calm", "bold"] | |
| Query: "a golden retriever" | |
| positive: ["golden retriever dog", "light golden fur", "medium to long wavy coat", "floppy ears", "broad head"] | |
| negative: ["short fur dog", "dark colored fur", "pointed upright ears", "small dog breed"] | |
| Query: "a person looking guilty" | |
| positive: ["worried", "nervous", "serious", "sad", "secretive", "uncomfortable"] | |
| negative: ["smiling", "happy", "confident", "laughing", "relaxed"] | |
| Query: "an exciting action scene" | |
| positive: ["sports", "jumping", "running", "movement"] | |
| negative: ["standing", "sitting", "calm", "still"] | |
| Query: "a friendly-looking dog" | |
| positive: ["happy", "playful", "gentle", "cute", "adorable", "wagging tail"] | |
| negative: ["aggressive", "scary", "angry", "mean", "threatening"] | |
| NOW GENERATE FOR THIS QUERY: | |
| USER QUERY: "{query}" | |
| INSTRUCTIONS: | |
| 1. Generate 5-8 POSITIVE attributes (observable visual cues that define what the user wants). | |
| - The first 1-2 should be the most important (weight 0.8-1.0). | |
| - The rest are supporting visual details (weight 0.4-0.7). | |
| - For subjective/emotional queries: describe body language, posture, facial features. | |
| 2. Generate 5-8 NEGATIVE attributes (visual opposites or CLIP confusions to suppress). | |
| - Weight 0.5-0.8 for strong opposites, 0.3-0.5 for subtle. | |
| 3. Set alpha=0.4 and beta=0.4 (safe defaults). Only increase to 0.45 for very specific queries. | |
| Return ONLY a JSON object (no markdown, no explanation): | |
| {{ | |
| "positive": [ | |
| {{"attribute": "short visual phrase", "weight": 0.9, "rationale": "why"}}, | |
| {{"attribute": "another phrase", "weight": 0.6, "rationale": "why"}} | |
| ], | |
| "negative": [ | |
| {{"attribute": "short visual phrase", "weight": 0.7, "rationale": "why"}}, | |
| {{"attribute": "another phrase", "weight": 0.5, "rationale": "why"}} | |
| ], | |
| "alpha": 0.4, | |
| "beta": 0.4 | |
| }}""" | |
| try: | |
| response = client.chat.completions.create( | |
| model="llama-3.3-70b-versatile", | |
| messages=[ | |
| { | |
| "role": "system", | |
| "content": ( | |
| "You are a vision-language retrieval expert specialising in CLIP embedding steering. " | |
| "You decompose queries into OBSERVABLE VISUAL attributes — body language, textures, " | |
| "colours, postures, spatial arrangements — never abstract or subjective words. " | |
| "Follow the few-shot examples closely. Return ONLY valid JSON." | |
| ), | |
| }, | |
| {"role": "user", "content": prompt}, | |
| ], | |
| temperature=0.3, | |
| max_tokens=1000, | |
| ) | |
| content = response.choices[0].message.content.strip() | |
| # Strip markdown fences | |
| if content.startswith("```json"): | |
| content = content[7:] | |
| if content.startswith("```"): | |
| content = content[3:] | |
| if content.endswith("```"): | |
| content = content[:-3] | |
| content = content.strip() | |
| feedback = json.loads(content) | |
| # Validate structure | |
| for attr_type in ("positive", "negative"): | |
| if attr_type not in feedback or not isinstance(feedback[attr_type], list): | |
| feedback[attr_type] = [] | |
| for attr in feedback[attr_type]: | |
| attr.setdefault("weight", 0.5) | |
| attr["weight"] = max(0.0, min(1.0, float(attr["weight"]))) | |
| attr.setdefault("rationale", "") | |
| feedback.setdefault("alpha", 0.4) | |
| feedback.setdefault("beta", 0.4) | |
| feedback["alpha"] = max(0.1, min(0.8, float(feedback["alpha"]))) | |
| feedback["beta"] = max(0.1, min(0.8, float(feedback["beta"]))) | |
| return feedback | |
| except Exception as exc: | |
| print(f"⚠️ LLM error: {exc}") | |
| return _fallback_feedback(query) | |
| # ============================================================================= | |
| # STEERING METHODS | |
| # ============================================================================= | |
| def baseline_retrieval( | |
| query_emb: np.ndarray, | |
| embeddings: np.ndarray, | |
| image_names: List[str], | |
| top_k: int = 5, | |
| ) -> List[Dict]: | |
| """Pure CLIP retrieval without steering.""" | |
| sims = embeddings @ query_emb | |
| top_idx = np.argsort(sims)[::-1][:top_k] | |
| return [{"image_name": image_names[i], "similarity": float(sims[i])} for i in top_idx] | |
| def linear_steering( | |
| query_emb: np.ndarray, | |
| positive_attrs: List[Dict], | |
| negative_attrs: List[Dict], | |
| alpha: float = 0.4, | |
| beta: float = 0.4, | |
| ) -> np.ndarray: | |
| """q' = q + α·Σ(w_i·p_i) - β·Σ(w_j·n_j)""" | |
| steered = query_emb.copy() | |
| for attr in positive_attrs: | |
| direction = get_text_embedding(f"a photo of {attr['attribute']}") | |
| steered += alpha * attr.get("weight", 1.0) * direction | |
| for attr in negative_attrs: | |
| direction = get_text_embedding(f"a photo of {attr['attribute']}") | |
| steered -= beta * attr.get("weight", 1.0) * direction | |
| return steered / np.linalg.norm(steered) | |
| def subspace_steering( | |
| query_emb: np.ndarray, | |
| positive_attrs: List[Dict], | |
| negative_attrs: List[Dict], | |
| ) -> np.ndarray: | |
| """Contrastive subspace (centroid-based) steering.""" | |
| pos_embs = [get_text_embedding(f"a photo of {a['attribute']}") for a in positive_attrs] | |
| neg_embs = [get_text_embedding(f"a photo of {a['attribute']}") for a in negative_attrs] | |
| if not pos_embs or not neg_embs: | |
| return query_emb | |
| direction = np.mean(pos_embs, axis=0) - np.mean(neg_embs, axis=0) | |
| direction /= np.linalg.norm(direction) | |
| steered = query_emb + 0.5 * direction | |
| return steered / np.linalg.norm(steered) | |
| def energy_based_steering( | |
| query_emb: np.ndarray, | |
| positive_attrs: List[Dict], | |
| negative_attrs: List[Dict], | |
| n_steps: int = 30, | |
| lr: float = 0.05, | |
| ) -> np.ndarray: | |
| """Energy-based steering via gradient descent.""" | |
| steered = query_emb.copy() | |
| original = query_emb.copy() | |
| pos_embs = [(get_text_embedding(f"a photo of {a['attribute']}"), a.get("weight", 1.0)) for a in positive_attrs] | |
| neg_embs = [(get_text_embedding(f"a photo of {a['attribute']}"), a.get("weight", 1.0)) for a in negative_attrs] | |
| for _ in range(n_steps): | |
| grad = np.zeros_like(steered) | |
| for emb, w in pos_embs: | |
| grad -= w * (emb - steered) | |
| for emb, w in neg_embs: | |
| grad += 0.5 * w * (emb - steered) | |
| grad += 0.1 * (steered - original) | |
| steered -= lr * grad | |
| return steered / np.linalg.norm(steered) | |
| def weighted_energy_steering( | |
| query_emb: np.ndarray, | |
| positive_attrs: List[Dict], | |
| negative_attrs: List[Dict], | |
| n_steps: int = 30, | |
| lr: float = 0.05, | |
| ) -> np.ndarray: | |
| """Weighted energy steering with normalised per-attribute weights.""" | |
| steered = query_emb.copy() | |
| original = query_emb.copy() | |
| pos_w = [a.get("weight", 1.0) for a in positive_attrs] | |
| neg_w = [a.get("weight", 1.0) for a in negative_attrs] | |
| if sum(pos_w) > 0: | |
| pos_w = [w / sum(pos_w) * len(pos_w) for w in pos_w] | |
| if sum(neg_w) > 0: | |
| neg_w = [w / sum(neg_w) * len(neg_w) for w in neg_w] | |
| pos_embs = [get_text_embedding(f"a photo of {a['attribute']}") for a in positive_attrs] | |
| neg_embs = [get_text_embedding(f"a photo of {a['attribute']}") for a in negative_attrs] | |
| for _ in range(n_steps): | |
| grad = np.zeros_like(steered) | |
| for emb, w in zip(pos_embs, pos_w): | |
| grad -= w * (emb - steered) | |
| for emb, w in zip(neg_embs, neg_w): | |
| grad += w * (emb - steered) | |
| grad += 0.1 * (steered - original) | |
| steered -= lr * grad | |
| return steered / np.linalg.norm(steered) | |