cotlab 0.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. cotlab/__init__.py +3 -0
  2. cotlab/analyse_experiments.py +392 -0
  3. cotlab/analysis/__init__.py +11 -0
  4. cotlab/analysis/cot_parser.py +243 -0
  5. cotlab/analysis/faithfulness_metrics.py +192 -0
  6. cotlab/backends/__init__.py +16 -0
  7. cotlab/backends/base.py +78 -0
  8. cotlab/backends/transformers_backend.py +335 -0
  9. cotlab/backends/vllm_backend.py +227 -0
  10. cotlab/cli.py +83 -0
  11. cotlab/core/__init__.py +34 -0
  12. cotlab/core/base.py +749 -0
  13. cotlab/core/config.py +90 -0
  14. cotlab/core/registry.py +68 -0
  15. cotlab/datasets/__init__.py +45 -0
  16. cotlab/datasets/loaders.py +1889 -0
  17. cotlab/experiment/__init__.py +315 -0
  18. cotlab/experiments/__init__.py +43 -0
  19. cotlab/experiments/activation_compare.py +290 -0
  20. cotlab/experiments/activation_patching.py +1050 -0
  21. cotlab/experiments/attention_analysis.py +885 -0
  22. cotlab/experiments/classification.py +235 -0
  23. cotlab/experiments/composite_shift_detector.py +524 -0
  24. cotlab/experiments/cot_ablation.py +277 -0
  25. cotlab/experiments/cot_faithfulness.py +187 -0
  26. cotlab/experiments/cot_heads.py +208 -0
  27. cotlab/experiments/full_layer_cot.py +232 -0
  28. cotlab/experiments/full_layer_patching.py +225 -0
  29. cotlab/experiments/h_neuron_analysis.py +712 -0
  30. cotlab/experiments/logit_lens.py +439 -0
  31. cotlab/experiments/multi_head_cot.py +220 -0
  32. cotlab/experiments/multi_head_patching.py +229 -0
  33. cotlab/experiments/probing_classifier.py +402 -0
  34. cotlab/experiments/residual_norm_ood.py +413 -0
  35. cotlab/experiments/sae_feature_analysis.py +673 -0
  36. cotlab/experiments/steering_vectors.py +223 -0
  37. cotlab/experiments/sycophancy_heads.py +224 -0
  38. cotlab/logging/__init__.py +5 -0
  39. cotlab/logging/json_logger.py +161 -0
  40. cotlab/main.py +317 -0
  41. cotlab/patching/__init__.py +24 -0
  42. cotlab/patching/cache.py +141 -0
  43. cotlab/patching/hooks.py +558 -0
  44. cotlab/patching/interventions.py +86 -0
  45. cotlab/patching/patcher.py +439 -0
  46. cotlab/patching/sae.py +181 -0
  47. cotlab/prompts/__init__.py +43 -0
  48. cotlab/prompts/cardiology.py +378 -0
  49. cotlab/prompts/histopathology.py +265 -0
  50. cotlab/prompts/length_matched_strategies.py +157 -0
  51. cotlab/prompts/mcq.py +193 -0
  52. cotlab/prompts/neurology.py +353 -0
  53. cotlab/prompts/oncology.py +367 -0
  54. cotlab/prompts/plab.py +162 -0
  55. cotlab/prompts/pubhealthbench.py +82 -0
  56. cotlab/prompts/pubmedqa.py +173 -0
  57. cotlab/prompts/radiology.py +414 -0
  58. cotlab/prompts/strategies.py +939 -0
  59. cotlab/prompts/tcga.py +168 -0
  60. cotlab/runner.py +204 -0
  61. cotlab-0.8.0.dist-info/METADATA +166 -0
  62. cotlab-0.8.0.dist-info/RECORD +65 -0
  63. cotlab-0.8.0.dist-info/WHEEL +4 -0
  64. cotlab-0.8.0.dist-info/entry_points.txt +3 -0
  65. cotlab-0.8.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,157 @@
1
+ """Length-matched prompt strategies for controlled comparison.
2
+
3
+ These strategies produce prompts with identical token counts,
4
+ eliminating prompt length as a confounding variable in
5
+ mechanistic analysis experiments.
6
+ """
7
+
8
+ from typing import Any, Dict, Optional
9
+
10
+ from ..core.base import BasePromptStrategy
11
+ from ..core.registry import Registry
12
+
13
+ # Target prompt template (longest version - others will pad to match)
14
+ # We use a standardized template where only the "instruction" varies
15
+ # but total token count stays the same via padding.
16
+
17
+ STANDARD_QUESTION_TEMPLATE = """You are a medical expert. Consider this clinical case carefully.
18
+
19
+ {instruction}
20
+
21
+ Case: {question}
22
+
23
+ {padding}Provide your analysis:"""
24
+
25
+
26
+ @Registry.register_prompt("contrarian_matched")
27
+ class ContrarianMatchedStrategy(BasePromptStrategy):
28
+ """
29
+ Length-matched contrarian strategy.
30
+
31
+ Matches token count with other strategies by using standardized template.
32
+ """
33
+
34
+ INSTRUCTION = """Play devil's advocate and argue against the obvious answer.
35
+ State the obvious diagnosis, then explain why it might be WRONG.
36
+ Consider alternative explanations that could fit the symptoms."""
37
+
38
+ PADDING = "" # No padding needed - this is the longest
39
+
40
+ def __init__(self, name: str = "contrarian_matched", **kwargs):
41
+ self._name = name
42
+
43
+ @property
44
+ def name(self) -> str:
45
+ return self._name
46
+
47
+ def build_prompt(self, input_data: Dict[str, Any]) -> str:
48
+ question = input_data.get("question", input_data.get("text", ""))
49
+ return STANDARD_QUESTION_TEMPLATE.format(
50
+ instruction=self.INSTRUCTION, question=question, padding=self.PADDING
51
+ )
52
+
53
+ def parse_response(self, response: str) -> Dict[str, Any]:
54
+ return {
55
+ "answer": response.strip(),
56
+ "reasoning": response,
57
+ "raw": response,
58
+ }
59
+
60
+ def get_system_message(self) -> Optional[str]:
61
+ return None # System message in prompt for consistency
62
+
63
+
64
+ @Registry.register_prompt("cot_matched")
65
+ class ChainOfThoughtMatchedStrategy(BasePromptStrategy):
66
+ """
67
+ Length-matched chain-of-thought strategy.
68
+
69
+ Matches token count with contrarian via padding.
70
+ """
71
+
72
+ INSTRUCTION = """Think through this problem step by step.
73
+ Reason carefully before giving your final answer.
74
+ Show your complete reasoning process."""
75
+
76
+ # Padding to match contrarian's longer instruction
77
+ PADDING = "Take your time. "
78
+
79
+ def __init__(self, name: str = "cot_matched", **kwargs):
80
+ self._name = name
81
+
82
+ @property
83
+ def name(self) -> str:
84
+ return self._name
85
+
86
+ def build_prompt(self, input_data: Dict[str, Any]) -> str:
87
+ question = input_data.get("question", input_data.get("text", ""))
88
+ return STANDARD_QUESTION_TEMPLATE.format(
89
+ instruction=self.INSTRUCTION, question=question, padding=self.PADDING
90
+ )
91
+
92
+ def parse_response(self, response: str) -> Dict[str, Any]:
93
+ return {
94
+ "answer": response.strip(),
95
+ "reasoning": response,
96
+ "raw": response,
97
+ }
98
+
99
+ def get_system_message(self) -> Optional[str]:
100
+ return None
101
+
102
+
103
+ @Registry.register_prompt("direct_matched")
104
+ class DirectAnswerMatchedStrategy(BasePromptStrategy):
105
+ """
106
+ Length-matched direct answer strategy.
107
+
108
+ Matches token count with contrarian via padding.
109
+ """
110
+
111
+ INSTRUCTION = """Give only the final diagnosis.
112
+ Do not explain your reasoning or show your work.
113
+ Answer with just the diagnosis name."""
114
+
115
+ # Padding to match contrarian's longer instruction
116
+ PADDING = "Be concise and direct. "
117
+
118
+ def __init__(self, name: str = "direct_matched", **kwargs):
119
+ self._name = name
120
+
121
+ @property
122
+ def name(self) -> str:
123
+ return self._name
124
+
125
+ def build_prompt(self, input_data: Dict[str, Any]) -> str:
126
+ question = input_data.get("question", input_data.get("text", ""))
127
+ return STANDARD_QUESTION_TEMPLATE.format(
128
+ instruction=self.INSTRUCTION, question=question, padding=self.PADDING
129
+ )
130
+
131
+ def parse_response(self, response: str) -> Dict[str, Any]:
132
+ return {
133
+ "answer": response.strip(),
134
+ "reasoning": None,
135
+ "raw": response,
136
+ }
137
+
138
+ def get_system_message(self) -> Optional[str]:
139
+ return None
140
+
141
+
142
+ # Utility function to verify token counts
143
+ def verify_token_counts(tokenizer, question: str = "Patient has chest pain.") -> Dict[str, int]:
144
+ """Verify that all matched strategies produce same token count."""
145
+ strategies = [
146
+ ContrarianMatchedStrategy(),
147
+ ChainOfThoughtMatchedStrategy(),
148
+ DirectAnswerMatchedStrategy(),
149
+ ]
150
+
151
+ counts = {}
152
+ for strategy in strategies:
153
+ prompt = strategy.build_prompt({"question": question})
154
+ tokens = tokenizer(prompt, return_tensors="pt")
155
+ counts[strategy.name] = tokens["input_ids"].shape[1]
156
+
157
+ return counts
cotlab/prompts/mcq.py ADDED
@@ -0,0 +1,193 @@
1
+ """MCQ (Multiple Choice Question) prompt strategy for medical benchmarks.
2
+
3
+ This is a generic prompt strategy for MCQ-style datasets like MedQA, MMLU, MedMCQA.
4
+ """
5
+
6
+ from typing import Any, Dict, Optional
7
+
8
+ from ..core.base import BasePromptStrategy, StructuredOutputMixin
9
+ from ..core.registry import Registry
10
+
11
+
12
+ @Registry.register_prompt("mcq")
13
+ class MCQPromptStrategy(BasePromptStrategy, StructuredOutputMixin):
14
+ """Generic MCQ prompt strategy for medical question answering."""
15
+
16
+ SYSTEM_ROLE = """You are an expert medical professional taking a medical licensing examination.
17
+ You must carefully analyze each question and select the single best answer from the options provided.
18
+ Think through your reasoning step by step before selecting your final answer."""
19
+
20
+ SYSTEM_ROLE_CONTRARIAN = """You are a skeptical medical expert reviewing a licensing question.
21
+ Critically analyze the scenario. Actively look for distractors and "red herrings" that might lead to a common trap answer.
22
+ Consider why the "obvious" choice might be wrong and justify your final selection with rigorous evidence."""
23
+
24
+ PROMPT_TEMPLATE = """## Medical Question
25
+
26
+ {question_text}
27
+
28
+ ## Instructions
29
+
30
+ Analyze this medical question carefully. Consider:
31
+ 1. Key clinical findings and patient presentation
32
+ 2. Relevant pathophysiology and mechanisms
33
+ 3. Standard diagnostic and treatment guidelines
34
+ 4. Why each option is correct or incorrect
35
+
36
+ After your analysis, select the single best answer from the provided options.
37
+
38
+ {format_instructions}"""
39
+
40
+ FEW_SHOT_EXAMPLES = [
41
+ {
42
+ "question": """A 45-year-old woman presents with fatigue, weight gain, and cold intolerance. Laboratory studies show elevated TSH and low free T4. Which of the following is the most likely diagnosis?
43
+
44
+ A) Graves' disease
45
+ B) Hashimoto's thyroiditis
46
+ C) Thyroid adenoma
47
+ D) Subacute thyroiditis""",
48
+ "reasoning": "The patient presents with classic symptoms of hypothyroidism: fatigue, weight gain, and cold intolerance. The lab findings confirm primary hypothyroidism with elevated TSH (the pituitary is trying to stimulate the thyroid) and low free T4 (the thyroid is not producing enough hormone). Hashimoto's thyroiditis is the most common cause of primary hypothyroidism in iodine-sufficient areas. Graves' disease causes hyperthyroidism. Thyroid adenoma typically presents as a nodule and doesn't usually cause hypothyroidism. Subacute thyroiditis can cause transient hypothyroidism but typically presents with pain and follows a viral illness.",
49
+ "answer": "B",
50
+ },
51
+ {
52
+ "question": """A 60-year-old man with a history of smoking presents with hemoptysis and a 3 cm lung mass on chest CT. Biopsy shows small round blue cells. Which of the following is the most appropriate next step?
53
+
54
+ A) Surgical resection
55
+ B) Radiation therapy alone
56
+ C) Chemotherapy with cisplatin and etoposide
57
+ D) Observation with repeat imaging in 3 months""",
58
+ "reasoning": "The biopsy showing small round blue cells in a smoker with a lung mass is diagnostic of small cell lung cancer (SCLC). SCLC is highly aggressive but initially chemosensitive. Unlike non-small cell lung cancer, surgical resection is not typically indicated for SCLC because it has usually metastasized by the time of diagnosis. The standard first-line treatment is combination chemotherapy with cisplatin (or carboplatin) and etoposide, often combined with radiation for limited-stage disease. Observation would be inappropriate given the aggressive nature of this malignancy.",
59
+ "answer": "C",
60
+ },
61
+ ]
62
+
63
+ def __init__(
64
+ self,
65
+ name: str = "mcq",
66
+ few_shot: bool = True,
67
+ output_format: str = "json",
68
+ answer_first: bool = False,
69
+ contrarian: bool = False,
70
+ cot_trigger: str = "Let's think through this step by step:",
71
+ **kwargs,
72
+ ):
73
+ self._name = name
74
+ self.few_shot = few_shot
75
+ self.output_format = output_format
76
+ self.answer_first = answer_first
77
+ self.contrarian = contrarian
78
+ self.cot_trigger = cot_trigger
79
+
80
+ @property
81
+ def name(self) -> str:
82
+ return self._name
83
+
84
+ def get_system_prompt(self) -> str:
85
+ return self.SYSTEM_ROLE_CONTRARIAN if self.contrarian else self.SYSTEM_ROLE
86
+
87
+ def build_prompt(
88
+ self,
89
+ inputs: Dict[str, Any],
90
+ few_shot: Optional[bool] = None,
91
+ **kwargs,
92
+ ) -> str:
93
+ use_few_shot = few_shot if few_shot is not None else self.few_shot
94
+
95
+ # Get the question text (already formatted with options by the dataset loader)
96
+ question_text = inputs.get("text", "")
97
+
98
+ # Build format instructions based on output format
99
+ format_instructions = self._get_format_instructions()
100
+
101
+ # Build few-shot examples if enabled
102
+ examples_str = ""
103
+ if use_few_shot:
104
+ examples_str = self._build_few_shot_examples()
105
+
106
+ # Construct the prompt
107
+ prompt = self.PROMPT_TEMPLATE.format(
108
+ question_text=question_text,
109
+ format_instructions=format_instructions,
110
+ )
111
+
112
+ if examples_str:
113
+ prompt = f"## Examples\n\n{examples_str}\n\n{prompt}"
114
+
115
+ if self.cot_trigger:
116
+ prompt += f"\n\n{self.cot_trigger}"
117
+
118
+ return prompt
119
+
120
+ def _build_few_shot_examples(self) -> str:
121
+ examples = []
122
+ for i, ex in enumerate(self.FEW_SHOT_EXAMPLES, 1):
123
+ if self.answer_first:
124
+ example = f"### Example {i}\n\n{ex['question']}\n\n**Answer:** {ex['answer']}\n\n**Reasoning:** {ex['reasoning']}"
125
+ else:
126
+ example = f"### Example {i}\n\n{ex['question']}\n\n**Reasoning:** {ex['reasoning']}\n\n**Answer:** {ex['answer']}"
127
+ examples.append(example)
128
+ return "\n\n".join(examples)
129
+
130
+ def _get_format_instructions(self) -> str:
131
+ if self.output_format == "json":
132
+ if self.answer_first:
133
+ return """Respond with a JSON object in this exact format:
134
+ ```json
135
+ {"answer": "X", "reasoning": "Your step-by-step explanation"}
136
+ ```
137
+ Where X is the letter (e.g., A, B, C, D, ...) of the correct answer."""
138
+ else:
139
+ return """Respond with a JSON object in this exact format:
140
+ ```json
141
+ {"reasoning": "Your step-by-step explanation", "answer": "X"}
142
+ ```
143
+ Where X is the letter (e.g., A, B, C, D, ...) of the correct answer."""
144
+ else:
145
+ if self.answer_first:
146
+ return "State your final answer as a single letter, followed by your reasoning."
147
+ else:
148
+ return "Provide your reasoning, then state your final answer as a single letter."
149
+
150
+ def parse_response(self, response: str) -> Dict[str, Any]:
151
+ """Parse model response to extract answer and reasoning."""
152
+ import json
153
+ import re
154
+
155
+ result = {"answer": None, "reasoning": None, "raw_response": response}
156
+
157
+ # Try JSON parsing first
158
+ try:
159
+ # Find JSON block
160
+ json_match = re.search(r"\{[^{}]*\}", response, re.DOTALL)
161
+ if json_match:
162
+ parsed = json.loads(json_match.group())
163
+ result["answer"] = parsed.get("answer", "").upper().strip()
164
+ result["reasoning"] = parsed.get("reasoning", "")
165
+ return result
166
+ except json.JSONDecodeError:
167
+ pass
168
+
169
+ # Fallback: Look for answer pattern
170
+ answer_patterns = [
171
+ r"(?:final\s+answer|answer|selection|choice)\s*[:\-]\s*([A-Z])",
172
+ r"(?:final\s+answer|answer)\s*(?:is|would be)\s*([A-Z])",
173
+ r"(?:^|\n)\s*([A-Z])\s*(?:\)|\.|\:)",
174
+ r"\*\*([A-Z])\*\*",
175
+ ]
176
+
177
+ for pattern in answer_patterns:
178
+ match = re.search(pattern, response, re.IGNORECASE)
179
+ if match:
180
+ result["answer"] = match.group(1).upper()
181
+ break
182
+
183
+ # If still no answer, look for lone letter
184
+ if not result["answer"]:
185
+ letters = re.findall(r"\b([A-Z])\b", response)
186
+ if letters:
187
+ result["answer"] = letters[-1].upper() # Take last mentioned
188
+
189
+ result["reasoning"] = response
190
+ return result
191
+
192
+ def get_prediction_field(self) -> str:
193
+ return "answer"