| 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.")
|
|
|