jira-task-duration-classifier / notebooks /rebuild_data_notebooks.py
omaradly's picture
Upload folder using huggingface_hub
018abf3 verified
Raw
History Blame Contribute Delete
13.5 kB
import json
from pathlib import Path
NOTEBOOK_META = {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3",
},
"language_info": {
"codemirror_mode": {"name": "ipython", "version": 3},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.13.5",
},
}
def markdown_cell(source):
return {
"cell_type": "markdown",
"metadata": {},
"source": source.splitlines(keepends=True),
}
def code_cell(source):
return {
"cell_type": "code",
"execution_count": None,
"metadata": {},
"outputs": [],
"source": source.splitlines(keepends=True),
}
def write_notebook(path, cells):
notebook = {
"cells": cells,
"metadata": NOTEBOOK_META,
"nbformat": 4,
"nbformat_minor": 5,
}
path.write_text(json.dumps(notebook, indent=1), encoding="utf-8")
root = Path(__file__).resolve().parents[1]
data_cleaning_cells = [
markdown_cell(
"""# 02 - Data Cleaning
Create `data/processed/jira_issues_cleaned.csv` from the raw Jira export. This keeps only fields known before resolution, plus dates needed by the next notebook to create `duration_days`."""
),
code_cell(
"""from pathlib import Path
import pandas as pd"""
),
code_cell(
"""PROJECT_ROOT = Path.cwd().parent if Path.cwd().name == "notebooks" else Path.cwd()
RAW_PATH = PROJECT_ROOT / "jira_ticket_dataset.csv"
OUTPUT_DIR = PROJECT_ROOT / "data" / "processed"
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
RAW_COLUMNS = [
"summary",
"description",
"priority.name",
"issuetype.name",
"project.key",
"projectCategory.name",
"votes.votes",
"watches.watchCount",
"labels",
"assignee",
"statusCategory.name",
"created",
"resolutiondate",
]
ticket_df = pd.read_csv(RAW_PATH, usecols=RAW_COLUMNS)
print(f"Raw rows loaded: {ticket_df.shape[0]:,}")
print(f"Raw columns loaded: {ticket_df.shape[1]:,}")"""
),
code_cell(
"""created_dates = pd.to_datetime(ticket_df["created"], errors="coerce")
resolution_dates = pd.to_datetime(ticket_df["resolutiondate"], errors="coerce")
completed_issue_mask = (
ticket_df["statusCategory.name"].eq("Done")
& created_dates.notna()
& resolution_dates.notna()
& (resolution_dates >= created_dates)
)
rows_before = len(ticket_df)
clean_df = ticket_df.loc[completed_issue_mask].copy()
print(f"Rows before completed issue filtering: {rows_before:,}")
print(f"Rows after completed issue filtering: {len(clean_df):,}")
print(f"Rows removed: {rows_before - len(clean_df):,}")"""
),
code_cell(
"""text_columns = ["summary", "description"]
for column in text_columns:
clean_df[column] = (
clean_df[column]
.fillna("")
.astype(str)
.str.replace(r"\\s+", " ", regex=True)
.str.strip()
)
categorical_mappings = {
"priority.name": "priority_name",
"issuetype.name": "issuetype_name",
"project.key": "project_key",
"projectCategory.name": "project_category_name",
}
for source_column, clean_column in categorical_mappings.items():
clean_df[clean_column] = (
clean_df[source_column]
.fillna("Unknown")
.astype(str)
.str.replace(r"\\s+", " ", regex=True)
.str.strip()
.replace("", "Unknown")
)
clean_df["summary_char_count"] = clean_df["summary"].str.len()
clean_df["summary_word_count"] = clean_df["summary"].str.split().str.len()
clean_df["description_char_count"] = clean_df["description"].str.len()
clean_df["description_word_count"] = clean_df["description"].str.split().str.len()
clean_df["has_description"] = (clean_df["description_word_count"] > 0).astype(int)
labels_text = clean_df["labels"].fillna("[]").astype(str).str.strip()
clean_df["labels_count"] = labels_text.str.count(",") + labels_text.ne("[]").astype(int)
clean_df["has_assignee"] = clean_df["assignee"].notna().astype(int)
clean_df["votes_votes"] = pd.to_numeric(clean_df["votes.votes"], errors="coerce").fillna(0).clip(lower=0)
clean_df["watches_watch_count"] = pd.to_numeric(clean_df["watches.watchCount"], errors="coerce").fillna(0).clip(lower=0)
for column in ["created", "resolutiondate"]:
clean_df[column] = pd.to_datetime(clean_df[column], errors="coerce")"""
),
code_cell(
"""cleaned_model_columns = [
"summary",
"description",
"priority_name",
"issuetype_name",
"project_key",
"project_category_name",
"summary_char_count",
"summary_word_count",
"description_char_count",
"description_word_count",
"has_description",
"labels_count",
"has_assignee",
"votes_votes",
"watches_watch_count",
"created",
"resolutiondate",
]
rows_before = len(clean_df)
model_df = clean_df[cleaned_model_columns].drop_duplicates().copy()
print(f"Rows before duplicate removal: {rows_before:,}")
print(f"Rows after duplicate removal: {len(model_df):,}")
print(f"Duplicate rows removed: {rows_before - len(model_df):,}")
missing_summary = pd.DataFrame({
"missing_count": model_df.isna().sum(),
"missing_percent": model_df.isna().mean().mul(100).round(2),
})
missing_summary.sort_values("missing_percent", ascending=False)"""
),
code_cell(
"""csv_path = OUTPUT_DIR / "jira_issues_cleaned.csv"
sample_path = OUTPUT_DIR / "jira_issues_cleaned_sample.csv"
model_df.to_csv(csv_path, index=False)
model_df.sample(n=min(100, len(model_df)), random_state=42).to_csv(sample_path, index=False)
print(f"Saved cleaned CSV file to: {csv_path}")
print(f"Saved sample CSV file to: {sample_path}")
print(f"Final cleaned rows: {model_df.shape[0]:,}")
print(f"Final cleaned columns: {model_df.shape[1]:,}")"""
),
]
feature_engineering_cells = [
markdown_cell(
"""# 03 - Feature Engineering
Build the modeling dataset from `jira_issues_cleaned.csv`. The target remains `duration_days -> duration_category`; the data shaping removes noisy boundary records, caps project/class dominance, and balances classes so Short and Long-running do not overwhelm Standard."""
),
code_cell(
"""from pathlib import Path
import numpy as np
import pandas as pd"""
),
code_cell(
"""PROJECT_ROOT = Path.cwd().parent if Path.cwd().name == "notebooks" else Path.cwd()
INPUT_PATH = PROJECT_ROOT / "data" / "processed" / "jira_issues_cleaned.csv"
OUTPUT_DIR = PROJECT_ROOT / "data" / "processed"
jira_df = pd.read_csv(INPUT_PATH)
task_df = jira_df.copy()
for column in ["created", "resolutiondate"]:
task_df[column] = pd.to_datetime(task_df[column], errors="coerce")
print(f"Cleaned source rows: {task_df.shape[0]:,}")
print(f"Cleaned source columns: {task_df.shape[1]:,}")"""
),
code_cell(
"""task_df["duration_days"] = (
task_df["resolutiondate"] - task_df["created"]
).dt.total_seconds() / (60 * 60 * 24)
rows_before = len(task_df)
task_df = task_df[
task_df["duration_days"].notna()
& (task_df["duration_days"] >= (2 / 24))
& (task_df["duration_days"] <= 90)
].copy()
print(f"Rows before duration filtering: {rows_before:,}")
print(f"Rows after duration filtering: {len(task_df):,}")
task_df["duration_days"].describe(percentiles=[0.25, 0.5, 0.75, 0.9, 0.95, 0.99])"""
),
code_cell(
"""def duration_category(days):
if days <= 3:
return "Short"
if days <= 15:
return "Standard"
return "Long-running"
duration_order = ["Short", "Standard", "Long-running"]
task_df["duration_category"] = task_df["duration_days"].apply(duration_category)
class_summary = pd.DataFrame({
"count": task_df["duration_category"].value_counts().reindex(duration_order),
"percent": task_df["duration_category"]
.value_counts(normalize=True)
.reindex(duration_order)
.mul(100)
.round(2),
})
class_summary"""
),
code_cell(
"""# Keep the full valid range for each duration class.
# The earlier duration filter already removed invalid and extreme examples.
duration_window_mask = (
(task_df["duration_category"].eq("Short") & (task_df["duration_days"] <= 3))
| (
task_df["duration_category"].eq("Standard")
& task_df["duration_days"].between(3, 15, inclusive="both")
)
| (
task_df["duration_category"].eq("Long-running")
& (task_df["duration_days"] >= 15)
)
)
rows_before = len(task_df)
task_df = task_df.loc[duration_window_mask].copy()
print(f"Rows removed outside full duration windows: {rows_before - len(task_df):,}")
print(f"Rows after duration-window check: {len(task_df):,}")"""
),
code_cell(
"""# Keep project/issue-type combinations where duration class has historical signal.
# This removes mixed groups that make Standard especially noisy, while retaining all classes.
group_columns = ["project_key", "issuetype_name"]
minimum_group_size = 25
minimum_category_share = 0.35
group_counts = (
task_df
.groupby(group_columns + ["duration_category"], observed=True)
.size()
.rename("category_count")
.reset_index()
)
group_totals = (
group_counts
.groupby(group_columns, observed=True)["category_count"]
.sum()
.rename("group_count")
.reset_index()
)
group_counts = group_counts.merge(group_totals, on=group_columns)
group_counts["category_share"] = group_counts["category_count"] / group_counts["group_count"]
consistent_groups = group_counts.loc[
(group_counts["group_count"] >= minimum_group_size)
& (group_counts["category_share"] >= minimum_category_share),
group_columns + ["duration_category"],
]
rows_before = len(task_df)
task_df = task_df.merge(
consistent_groups,
on=group_columns + ["duration_category"],
how="inner",
)
print(f"Rows removed from low-signal project/issue groups: {rows_before - len(task_df):,}")
print(f"Rows after consistency filtering: {len(task_df):,}")
task_df["duration_category"].value_counts().reindex(duration_order)"""
),
code_cell(
"""# Prevent a few large projects from dominating the classifier.
max_rows_per_project_class = 2_500
task_df = (
task_df
.groupby(["project_key", "duration_category"], group_keys=False, observed=True)
.apply(
lambda group: group.sample(
n=min(len(group), max_rows_per_project_class),
random_state=42,
)
)
.reset_index(drop=True)
)
print(f"Rows after project/class cap: {len(task_df):,}")
task_df["duration_category"].value_counts().reindex(duration_order)"""
),
code_cell(
"""# Balance classes without duplicating rows. Standard is often the hardest class, so the
# final class size is anchored to the smallest available class after cleanup.
class_counts = task_df["duration_category"].value_counts()
target_class_size = int(class_counts.min())
balanced_parts = []
for category in duration_order:
category_df = task_df.loc[task_df["duration_category"].eq(category)]
balanced_parts.append(
category_df.sample(n=target_class_size, random_state=42)
)
task_df = (
pd.concat(balanced_parts, ignore_index=True)
.sample(frac=1, random_state=42)
.reset_index(drop=True)
)
balanced_summary = pd.DataFrame({
"count": task_df["duration_category"].value_counts().reindex(duration_order),
"percent": task_df["duration_category"]
.value_counts(normalize=True)
.reindex(duration_order)
.mul(100)
.round(2),
})
print(f"Target rows per class: {target_class_size:,}")
balanced_summary"""
),
code_cell(
"""task_df["created_year"] = task_df["created"].dt.year
task_df["created_month"] = task_df["created"].dt.month
final_cleaned_columns = [
"summary",
"description",
"priority_name",
"issuetype_name",
"project_key",
"project_category_name",
"summary_char_count",
"summary_word_count",
"description_char_count",
"description_word_count",
"has_description",
"labels_count",
"has_assignee",
"votes_votes",
"watches_watch_count",
"created_year",
"created_month",
"duration_category",
]
final_cleaned_df = task_df[final_cleaned_columns].copy()
final_cleaned_path = OUTPUT_DIR / "final_cleaned.csv"
sample_path = OUTPUT_DIR / "final_cleaned_sample.csv"
final_cleaned_df.to_csv(final_cleaned_path, index=False)
final_cleaned_df.sample(n=min(100, len(final_cleaned_df)), random_state=42).to_csv(sample_path, index=False)
print(f"Saved final cleaned CSV file to: {final_cleaned_path}")
print(f"Saved sample CSV file to: {sample_path}")
print(f"Final modeling rows: {final_cleaned_df.shape[0]:,}")
print(f"Final modeling columns: {final_cleaned_df.shape[1]:,}")"""
),
]
write_notebook(root / "notebooks" / "02-data-cleaning.ipynb", data_cleaning_cells)
write_notebook(root / "notebooks" / "03-feature-engineering.ipynb", feature_engineering_cells)
print("Rebuilt data cleaning and feature engineering notebooks.")