Experimental-Aether-Framework / evaluator_module.py
Supastrikas-004's picture
Update evaluator_module.py (#40)
dcdfcaa verified
Raw
History Blame Contribute Delete
12.5 kB
import re
import numpy as np
from typing import Dict, List, Optional, Tuple
import json
from collections import defaultdict
import spacy
from transformers import pipeline
from sentence_transformers import SentenceTransformer
from sklearn.metrics.pairwise import cosine_similarity
import hashlib
from datetime import datetime
import concurrent.futures
import random
class AetherScoreEvaluator:
def __init__(self):
# NLP models
try:
self.nlp = spacy.load("en_core_web_sm")
except OSError:
print("Downloading 'en_core_web_sm' spacy model...")
spacy.cli.download("en_core_web_sm")
self.nlp = spacy.load("en_core_web_sm")
# LLM Judge Model
self.judge_model = pipeline(
"text2text-generation",
model="google/flan-t5-base",
device=-1 # 0 for GPU
)
# Sentence Transformer for Sentence ----> Embedding
self.sentence_model = SentenceTransformer('all-MiniLM-L6-v2')
# Scoring weights # Domain Specific weights can be added for better results
self.weights = {'instruction_following': 0.25, 'hallucination_score': 0.20,
'assumption_control': 0.20, 'coherence': 0.20, 'accuracy': 0.15}
# In-memory cache
self.cache = {}
# LLM Judge
# def _evaluate_with_llm_judge(self, prompt: str, response: str) -> Dict:
# print("Using HF model LLM Judge...")
# query = (
# f"Prompt: {prompt}\n"
# f"Response: {response}\n\n"
# "Return ONLY a valid JSON object in this exact format:\n"
# "{\n"
# " \"hallucination_score\": <float between 0 and 1>,\n"
# " \"assumption_control\": <float between 0 and 1>,\n"
# " \"explanation\": \"<one or two sentences>\"\n"
# "}"
# )
# try:
# result = self.judge_model(query, max_new_tokens=128, truncation=True)
# output = result[0]['generated_text']
# except Exception as e:
# return {
# "hallucination_score": (0.1, f"HF model failed: {e}"),
# "assumption_control": (0.1, f"HF model failed: {e}")
# }
# # Default values
# halluc_score = random.uniform(0.3, 0.7)
# assumption_score = random.uniform(0.3, 0.7)
# explanation = output
# try:
# parsed = json.loads(output.strip())
# halluc_score = float(parsed.get("hallucination_score", halluc_score))
# assumption_score = float(parsed.get("assumption_control", assumption_score))
# explanation = parsed.get("explanation", explanation)
# except Exception:
# pass # fallback to defaults
# return {
# "hallucination_score": (halluc_score, explanation),
# "assumption_control": (assumption_score, explanation)
# }
import random
import json
def _evaluate_with_llm_judge(self, prompt: str, response: str) -> Dict:
print("Using rule-based evaluation instead of HF LLM...")
prompt_words = set(prompt.lower().split())
response_words = response.lower().split()
# Hallucination score: fraction of words in response that are not in prompt
if response_words:
halluc_score = len([w for w in response_words if w not in prompt_words]) / len(response_words)
else:
halluc_score = 0.1
# Assumption control: fraction of sentences starting with uncertain words
uncertain_starts = ("i assume", "maybe", "probably", "likely", "could be")
sentences = response.lower().split(".")
if sentences:
assumption_score = sum(0.3 for s in sentences if s.strip().startswith(uncertain_starts)) / len(sentences)
else:
assumption_score = 0.1
# Ensure scores are between 0 and 1
# halluc_score = max(0, min(1, halluc_score))
# assumption_score = max(0, min(1, assumption_score))
explanation = "Rule-based evaluation applied."
return {
"hallucination_score": (halluc_score, explanation),
"assumption_control": (assumption_score, explanation)
}
# Single Evaluation # Inputs-->> Prompt, Agent Response, Expected Answer(Optional), Agent Name and Task type( General, QA, Summarizaton)etc
def evaluate_single(self, prompt: str, response: str, expected_answer: Optional[str] = None, task_type: str = "general") -> Dict:
# Generating Eval ID
eval_id = self._generate_eval_id(prompt, response)
# If already stored in cache direclty we can return from there.
# if eval_id in self.cache:
# return self.cache[eval_id]
scores, reasons = {}, {}
# Taking Back scores and reasons of hallucination and assumption control from LLM Judge
llm_judge_results = self._evaluate_with_llm_judge(prompt, response)
scores['hallucination_score'], reasons['hallucination_score'] = llm_judge_results['hallucination_score']
scores['assumption_control'], reasons['assumption_control'] = llm_judge_results['assumption_control']
# Evaluating Instruction Following, Coherence and Accuracy
scores['instruction_following'], reasons['instruction_following'] = self._evaluate_instruction_following(prompt, response)
scores['coherence'], reasons['coherence'] = self._evaluate_coherence(response)
scores['accuracy'], reasons['accuracy'] = self._evaluate_accuracy(response, expected_answer, task_type) if expected_answer else (0.5, "No expected answer provided.")
# Calculating Overall Score
scores['overall_score'] = self._calculate_overall_score(scores)
reasons['overall_score'] = f" Weighted Average Score based on component scores."
# Updating Eval ID, Timestamp and Task Type in Scores
scores.update({'eval_id': eval_id, 'timestamp': datetime.now().isoformat(), 'task_type': task_type})
# Updating scores(Eval ID, timestamp and task_type) and reasons(all scores) in result
result = {"scores": scores, "reasons": reasons}
#Storing results with corresponding Eval ID in cache
# self.cache[eval_id] = result
return result
# Batch Evaluation # Input of JSON/CSV file
def evaluate_batch(self, data: List[Dict], mode: str = "comprehensive") -> List[Dict]:
"""Process a batch of evaluations in parallel."""
results = []
# Get Item function
def process_item(item):
# Calling our Evalution function for Single prompt response pair
eval_result = self.evaluate_single(
prompt=item.get('prompt', ''),
response=item.get('response', ''),
expected_answer=item.get('expected_answer',''),
task_type=item.get('task_type', 'general')
)
# Combining with original metadata
eval_result.update({
'task_id': item.get('task_id', eval_result['scores']['eval_id']),
'agent_name': item.get('agent_name', 'Unknown'),
})
return eval_result
with concurrent.futures.ThreadPoolExecutor() as executor:
future_to_item = {executor.submit(process_item, item): item for item in data}
for future in concurrent.futures.as_completed(future_to_item):
try:
results.append(future.result())
except Exception as exc:
print(f'An item generated an exception: {exc}')
return results
# Instruction Following Evaluation (Prompt, Response)
def _evaluate_instruction_following(self, prompt: str, response: str) -> Tuple[float, str]:
score, checks, passed = 1.0, 0, 0
# Check for negative constraints
negations = re.findall(r"(don't|do not|avoid|without) ([\w\s,]+)", prompt.lower())
for _, constraint_phrase in negations:
checks += 1
words_to_avoid = [w.strip() for w in constraint_phrase.split(',')]
if not any(word in response.lower() for word in words_to_avoid if len(word) > 2):
passed += 1
# Fallback to semantic similarity if no specific instructions found
if checks == 0:
sim = self._semantic_similarity(prompt, response)
return sim, f"No specific constraints found. Score based on semantic similarity ({sim:.2f}) to prompt."
# Final Score calculation
score = passed / checks if checks > 0 else 1.0
reason = f"{passed}/{checks} specific constraints were followed."
return score, reason
# Evaluating Coherence (response)
def _evaluate_coherence(self, response: str) -> Tuple[float, str]:
# Extracting Sentences from Response
doc = self.nlp(response)
sentences = [sent.text for sent in doc.sents]
# If only one Sentence then Coherence is Neutral
if len(sentences) < 2:
return 0.7, "Coherence is neutral for single-sentence responses."
# Fetching Embeddings from our Sentence Model
embeddings = self.sentence_model.encode(sentences)
sims = [cosine_similarity([embeddings[i]], [embeddings[i+1]])[0][0] for i in range(len(sentences)-1)]
score = np.mean(sims)
reason = f"Average sentence-to-sentence similarity score is {score:.2f} across {len(sentences)} sentences."
return score, reason
# Evaluating Accuracy (Response, Expected, Task_type)
def _evaluate_accuracy(self, response: str, expected: str, task_type: str) -> Tuple[float, str]:
sim = self._semantic_similarity(response, expected)
reason = f"Semantic similarity between response and expected answer is {sim:.2f}."
if sim > 0.95:
reason += " (High match)"
elif sim < 0.5:
reason += " (Low match)"
return sim, reason
# Overall Score
def _calculate_overall_score(self, scores: Dict) -> float:
total, weight_sum = 0.0, 0.0
for metric, weight in self.weights.items():
if metric in scores:
total += scores[metric] * weight
weight_sum += weight
return total / weight_sum #if weight_sum > 0 else 0.5
# Explanation Generator, work in progress
def generate_explanation(self, scores: Dict) -> str:
explanation = []
overall = scores.get('overall_score', 0)
explanation.append(f"Overall Score: {overall:.2f}/1.00 - Reflects a weighted average of all dimensions.")
if scores.get('instruction_following', 0) < 0.6:
explanation.append("⚠️ Low Instruction Following: The response may have ignored key constraints or parts of the prompt.")
if scores.get('hallucination_score', 0) < 0.6:
explanation.append("⚠️ Potential Hallucination: The response might contain unverified or fabricated information.")
if scores.get('accuracy', 0) < 0.6 and scores.get('accuracy', 0.5) != 0.5:
explanation.append("⚠️ Low Accuracy: The response significantly differs from the provided expected answer.")
if not explanation[1:]:
explanation.append("✅ Great Performance: The agent performed well across the primary evaluation dimensions.")
return "\n".join(explanation)
# Agent Scores
def get_agent_scores_from_results(self, results: List[Dict]) -> Dict[str, List[float]]:
agent_scores = defaultdict(list)
for result in results:
agent_name = result.get('agent_name', 'Unknown')
overall_score = result.get('scores', {}).get('overall_score', 0)
agent_scores[agent_name].append(overall_score)
return agent_scores
# Helper Functions
def _generate_eval_id(self, prompt: str, response: str) -> str:
return hashlib.md5(f"{prompt}{response}".encode()).hexdigest()[:12]
def _semantic_similarity(self, text1: str, text2: str) -> float:
if not text1 or not text2: return 0.0
emb1 = self.sentence_model.encode([text1])
emb2 = self.sentence_model.encode([text2])
return cosine_similarity(emb1, emb2)[0][0]