File size: 7,467 Bytes
6f5f419
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
038e07e
 
 
 
6f5f419
038e07e
6f5f419
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
import torch
import pandas as pd

from sentence_transformers import SentenceTransformer, util
from transformers import AutoTokenizer, AutoModelForCausalLM, pipeline

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

PMFC_JSON_PATH = "assets/pmfc.json"

EMBED_MODEL_NAME = "sentence-transformers/all-MiniLM-L6-v2"
CLASSIFIER_MODEL_NAME = "Qwen/Qwen2.5-1.5B-Instruct"


# =========================
# LAZY-LOADED MODEL / DATA CACHE
# =========================
_CACHE = {
    "embedder": None,
    "tokenizer": None,
    "model": None,
    "pipe": None,
    "pm_examples": None,    # DataFrame
    "fc_examples": None,    # DataFrame
    "pm_embeddings": None,
    "fc_embeddings": None,
}


def _get_embedder():
    if _CACHE["embedder"] is None:
        _CACHE["embedder"] = SentenceTransformer(EMBED_MODEL_NAME, device=str(device))
    return _CACHE["embedder"]


def _get_pipe():
    if _CACHE["pipe"] is None:
        tokenizer = AutoTokenizer.from_pretrained(CLASSIFIER_MODEL_NAME)
        model = AutoModelForCausalLM.from_pretrained(
            CLASSIFIER_MODEL_NAME,
            torch_dtype=torch.float16 if device.type == "cuda" else torch.float32,
            device_map="auto" if device.type == "cuda" else None
        )
        if device.type == "cpu":
            model.to(device)

        _CACHE["tokenizer"] = tokenizer
        _CACHE["model"] = model
        _CACHE["pipe"] = pipeline(
            "text-generation",
            model=model,
            tokenizer=tokenizer,
            return_full_text=False,
            do_sample=False
        )
    return _CACHE["pipe"], _CACHE["tokenizer"]


def _load_pmfc_corpus():
    """Loads assets/pmfc.json and pre-computes PM/FC embeddings separately,
    same split logic as the reference script (str.contains on FinalAssessment)."""
    if _CACHE["pm_examples"] is not None:
        return

    examples_df = pd.read_json(PMFC_JSON_PATH)

    pm_examples = examples_df[
        examples_df["FinalAssessment"].str.contains("PM", na=False)
    ].reset_index(drop=True)

    fc_examples = examples_df[
        examples_df["FinalAssessment"].str.contains("FC", na=False)
    ].reset_index(drop=True)

    embedder = _get_embedder()

    pm_embeddings = embedder.encode(
        pm_examples["Paragraph_content"].tolist(),
        convert_to_tensor=True,
        normalize_embeddings=True
    )
    fc_embeddings = embedder.encode(
        fc_examples["Paragraph_content"].tolist(),
        convert_to_tensor=True,
        normalize_embeddings=True
    )

    _CACHE["pm_examples"] = pm_examples
    _CACHE["fc_examples"] = fc_examples
    _CACHE["pm_embeddings"] = pm_embeddings
    _CACHE["fc_embeddings"] = fc_embeddings


# =========================
# PROMPT (matches reference script's generate_pmfc_prompt exactly)
# =========================
def generate_pmfc_prompt(paragraph, pm_example, fc_example):
    prompt = f"""
You are an expert sustainability-report analyst.

The paragraph below is already known to be:
- Sustainability Relevant
- Quantitative

Your task is to classify it into EXACTLY ONE category.

Definitions

Performance Metrics (PM):
- Reports achievements
- Reports measured performance
- Reports historical results
- Reports current status
- Reports quantities already achieved

Future Commitments (FC):
- Reports future targets
- Reports future goals
- Reports planned improvements
- Reports intended reductions or increases
- Reports commitments to be achieved later

Example 1

Paragraph:
{pm_example}

Answer: PM


Example 2

Paragraph:
{fc_example}

Answer: FC


Paragraph to classify:

{paragraph}

Respond with EXACTLY one of:

Answer: PM

or

Answer: FC

Do not explain.
Do not provide reasoning.
"""
    return prompt


def classify_pmfc(paragraph, pm_example, fc_example):
    """Matches the reference script's classify_pmfc: generates up to 5 new
    tokens, then checks the (uppercased) output for 'PM' or 'FC'."""
    pipe, tokenizer = _get_pipe()

    prompt = generate_pmfc_prompt(paragraph, pm_example, fc_example)

    output = pipe(
        prompt,
        max_new_tokens=5,
        pad_token_id=tokenizer.eos_token_id
    )[0]["generated_text"]

    output_upper = output.strip().upper()

    if "PM" in output_upper:
        return "Answer: PM"
    if "FC" in output_upper:
        return "Answer: FC"
    return "UNKNOWN"


# =========================
# FULL PIPELINE: run on a quantitative-only subset of a DataFrame
# =========================
def classify_quantitative_paragraphs(df, qq_column):
    """
    Mirrors the reference script's full flow:
      1. Filter df to rows where qq_column == "Quantitative"
      2. Retrieve top-1 most similar PM example and top-1 most similar FC
         example (independently) for every quantitative paragraph
      3. Classify each one via one-shot prompting against Qwen2.5-1.5B
      4. Merge PMFC_Label (and the retrieved examples + similarity scores,
         needed for the per-paragraph CSV export) back into the full df

    Returns the full df with new columns added:
      PMFC_Label, EX_PM, FA_PM, PM_Similarity, EX_FC, FA_FC, FC_Similarity
    (non-quantitative rows get blank/NaN in these columns, same as the
    reference script's df["PMFC_Label"] = "" then partial assignment)
    """
    _load_pmfc_corpus()
    embedder = _get_embedder()

    df = df.copy()
    # Use object dtype with pd.NA placeholders (not "") so that the
    # numeric columns (PM_Similarity, FC_Similarity) can later receive
    # float values without hitting pandas' Arrow-backed string dtype
    # restriction (TypeError: Invalid value for dtype 'str').
    for col in ["PMFC_Label", "EX_PM", "FA_PM", "PM_Similarity", "EX_FC", "FA_FC", "FC_Similarity"]:
        df[col] = pd.Series([pd.NA] * len(df), index=df.index, dtype="object")

    quant_df = df[df[qq_column] == "Quantitative"].copy()

    if quant_df.empty:
        return df

    quant_embeddings = embedder.encode(
        quant_df["Paragraph_content"].tolist(),
        convert_to_tensor=True,
        normalize_embeddings=True
    )

    pm_scores = util.cos_sim(quant_embeddings, _CACHE["pm_embeddings"])
    pm_best_idx = torch.argmax(pm_scores, dim=1)

    fc_scores = util.cos_sim(quant_embeddings, _CACHE["fc_embeddings"])
    fc_best_idx = torch.argmax(fc_scores, dim=1)

    pm_examples = _CACHE["pm_examples"]
    fc_examples = _CACHE["fc_examples"]

    predictions = []
    ex_pm_list, ex_fc_list = [], []
    pm_sim_list, fc_sim_list = [], []

    for i in range(len(quant_df)):
        pm_idx = pm_best_idx[i].item()
        fc_idx = fc_best_idx[i].item()

        pm_text = pm_examples.iloc[pm_idx]["Paragraph_content"]
        fc_text = fc_examples.iloc[fc_idx]["Paragraph_content"]

        ex_pm_list.append(pm_text)
        ex_fc_list.append(fc_text)
        pm_sim_list.append(float(pm_scores[i, pm_idx].item()))
        fc_sim_list.append(float(fc_scores[i, fc_idx].item()))

        pred = classify_pmfc(quant_df.iloc[i]["Paragraph_content"], pm_text, fc_text)
        predictions.append(pred)

    quant_df["PMFC_Label"] = predictions
    quant_df["EX_PM"] = ex_pm_list
    quant_df["FA_PM"] = "PM"
    quant_df["PM_Similarity"] = pm_sim_list
    quant_df["EX_FC"] = ex_fc_list
    quant_df["FA_FC"] = "FC"
    quant_df["FC_Similarity"] = fc_sim_list

    for col in ["PMFC_Label", "EX_PM", "FA_PM", "PM_Similarity", "EX_FC", "FA_FC", "FC_Similarity"]:
        df.loc[quant_df.index, col] = quant_df[col]

    return df