Inteligencia Analítica en Construcción

EVM · Presupuesto APU · Modelos Predictivos · Power BI + Python Dash

Power BI
Python
Dash
EVM
Construcción
CRISP-DM
Analítica de Negocios

Dashboard de control de proyectos para una constructora colombiana con portafolio de $50.8B COP. Análisis EVM, predicción de costos al cierre (CRISP-DM), scorecard de contratistas y visualización en Power BI y Python Dash.

Autor/a

LCPallares

Fecha de publicación

septiembre 2024

Resumen del proyecto

Este proyecto simula el sistema de inteligencia analítica de Constructora Andina Proyectos S.A.S., empresa colombiana con un portafolio activo de 8 proyectos de obra civil y edificación.

El objetivo es demostrar cómo las técnicas de análisis de datos — combinadas con conocimiento del sector construcción colombiano — permiten tomar mejores decisiones en la gestión de proyectos.

Datos sintéticos generados con distribuciones realistas según estándares INVIAS, Camacol y Contraloría General de la República. Los perfiles de CPI/SPI respetan los rangos típicos de obra civil colombiana (0.85–1.05).


Contexto de negocio

¿Por qué EVM en construcción colombiana?

En Colombia, la mayoría de proyectos de obra pública se ejecutan bajo contratos de precio global fijo o precios unitarios, lo que hace que el control de costos sea crítico. El Earned Value Management (EVM) es el estándar internacional (PMBOK, NTC-ISO 21500) para medir simultáneamente desempeño en costo y cronograma.

KPIs ejecutivos del portafolio — Corte septiembre 2024
Indicador Portafolio (Sep 2024) Interpretación
CPI 0.923 Por cada $1 gastado se produce $0.92 de valor
SPI 0.948 Avanzando al 94.8% de la velocidad planeada
EAC $55.141 B COP Proyección de costo final
Sobrecosto $4.261 B COP Desviación estimada al cierre

Portafolio de proyectos

Ver código
df_tbl = ultimo[["id_proyecto","nombre","tipo_proyecto","CPI","SPI","avance_fisico_pct","presupuesto_oficial"]].copy()
df_tbl["EAC"] = df_tbl["presupuesto_oficial"] / df_tbl["CPI"]
df_tbl["Desv %"] = (df_tbl["EAC"] - df_tbl["presupuesto_oficial"]) / df_tbl["presupuesto_oficial"] * 100
df_tbl["Semáforo"] = df_tbl["CPI"].apply(
    lambda v: "🔴 CRÍTICO" if v < 0.85 else ("🟡 ATENCIÓN" if v < 0.95 else "🟢 NORMAL")
)

display = df_tbl[["nombre","tipo_proyecto","presupuesto_oficial","CPI","SPI","avance_fisico_pct","Desv %","Semáforo"]].copy()
display.columns = ["Proyecto","Tipo","BAC (COP)","CPI","SPI","Avance %","Desv %","Estado"]
display["BAC (COP)"] = display["BAC (COP)"].apply(lambda v: f"${v/1e9:.3f} B")
display["CPI"] = display["CPI"].apply(lambda v: f"{v:.3f}")
display["SPI"] = display["SPI"].apply(lambda v: f"{v:.3f}")
display["Avance %"] = display["Avance %"].apply(lambda v: f"{v:.0f}%")
display["Desv %"] = display["Desv %"].apply(lambda v: f"+{v:.1f}%")
display.set_index("Proyecto", inplace=True)
display
Tabla 1: 8 proyectos activos — estado al corte Sep 2024
Tipo BAC (COP) CPI SPI Avance % Desv % Estado
Proyecto
Via Terciaria Km 12 - Km 28 Vias $4.800 B 0.881 0.890 88% +13.5% 🟡 ATENCIÓN
Edificio Oficinas Zona Rosa 8 pisos Edificacion $12.500 B 0.963 0.969 89% +3.9% 🟢 NORMAL
Puente Vehicular Rio Magdalena Puentes $8.200 B 0.850 0.951 61% +17.7% 🔴 CRÍTICO
Parque Industrial Zona Franca Urbanizacion $6.400 B 0.948 0.956 29% +5.5% 🟡 ATENCIÓN
Colector Aguas Residuales Zona Norte Redes $3.100 B 0.877 0.944 67% +14.0% 🟡 ATENCIÓN
Ampliacion Hospital Regional Edificacion $5.600 B 0.993 0.992 29% +0.7% 🟢 NORMAL
Estudio Geotecnico Variante Norte Consultoria $0.480 B 0.978 0.972 55% +2.2% 🟢 NORMAL
Urbanizacion Residencial 200 Casas Urbanizacion $9.800 B 0.938 0.937 60% +6.6% 🟡 ATENCIÓN

Stack técnico

El proyecto integra dos capas de visualización complementarias:

Power BI

  • Modelo estrella con 4 dimensiones y 5 tablas de hechos
  • Medidas DAX para CPI, SPI, EAC, alertas semafóricas
  • 5 páginas completas entregadas como medidas HTML Content
  • Cross-filtering nativo entre visualizaciones
  • Fuente Barlow Condensed (equivalente DIN) vía Google Fonts

Python Dash

  • App completa con navegación lateral de 5 páginas
  • Datos cargados directamente desde los CSV con pandas
  • Gráficas interactivas con plotly (curvas S, scatter, tablas)
  • Cross-filtering manual: click en barra → filtra scatter + tabla
  • ~1200 líneas, sin dependencias de BI propietario
construccion_analitica.py Python · pandas · numpy genera 11 CSV data_powerbi/ importa lee Power BI Dashboard · DAX · HTML Python Dash App · Plotly · pandas Stakeholder / Analista
Arquitectura del proyecto: Python genera los datos, Power BI y Dash los visualizan

Generación de datos sintéticos

El script construccion_analitica.py genera 11 CSV con datos realistas usando:

  • Perfiles de rendimiento por proyecto — cada uno tiene un rc (ratio de costo final) y rp (ratio de plazo) que determinan la trayectoria de CPI/SPI
  • Curva S sigmoide — el avance mensual sigue (s - s0) / (s1 - s0) para que la forma S sea matemáticamente correcta (sin negativos)
  • CPI/SPI graduales — función factor_mes() que interpola de 1.0 → rc a lo largo del plazo con inflexión en el 35%, imitando el comportamiento real de obra
Ver código
df_perf = ultimo[["id_proyecto","nombre","CPI","SPI","presupuesto_oficial"]].copy()
df_perf["color"] = df_perf["CPI"].apply(sem_color)
df_perf["tamaño"] = df_perf["presupuesto_oficial"] / 4e8

fig = go.Figure()

# Zonas de fondo
fig.add_shape(type="rect", x0=0.85, y0=0.85, x1=0.95, y1=1.05,
    fillcolor="rgba(254,243,226,0.5)", line_width=0)
fig.add_shape(type="rect", x0=0.85, y0=0.85, x1=0.85, y1=1.05,
    fillcolor="rgba(253,232,232,0.5)", line_width=0)

# Líneas de referencia
for val, color, dash, label in [
    (1.0, C["green"], "dot", "Óptimo"),
    (0.95, C["amber"], "dash", "Atención"),
    (0.85, C["red"], "dash", "Crítico"),
]:
    fig.add_hline(y=val, line_color=color, line_dash=dash, line_width=1.2, opacity=0.6,
                  annotation_text=f"SPI {val}", annotation_position="right",
                  annotation_font=dict(size=9, color=color))
    fig.add_vline(x=val, line_color=color, line_dash=dash, line_width=1.2, opacity=0.6)

for _, row in df_perf.iterrows():
    fig.add_trace(go.Scatter(
        x=[row["CPI"]], y=[row["SPI"]],
        mode="markers+text",
        marker=dict(size=row["tamaño"], color=row["color"],
                    opacity=0.85, line=dict(width=2, color="white")),
        text=[row["id_proyecto"]],
        textposition="middle center",
        textfont=dict(size=9, color="white", family="Barlow Condensed, sans-serif"),
        hovertemplate=(
            f"<b>{row['nombre']}</b><br>"
            f"CPI: {row['CPI']:.3f}<br>"
            f"SPI: {row['SPI']:.3f}<br>"
            f"BAC: ${row['presupuesto_oficial']/1e9:.2f} B<extra></extra>"
        ),
        showlegend=False,
    ))

fig.update_layout(
    height=380,
    margin=dict(l=50, r=100, t=30, b=50),
    paper_bgcolor="white", plot_bgcolor=C["slate"],
    xaxis=dict(title="CPI (Cost Performance Index)", range=[0.82, 1.04],
               gridcolor="#eef2f7", tickfont=dict(size=10)),
    yaxis=dict(title="SPI (Schedule Performance Index)", range=[0.84, 1.02],
               gridcolor="#eef2f7", tickfont=dict(size=10)),
    font=dict(family="Barlow, sans-serif", size=11, color=C["muted"]),
    annotations=[dict(
        x=0.83, y=1.01,
        text="<b>Tamaño = BAC</b><br>🔴 CPI < 0.85 · 🟡 0.85–0.95 · 🟢 > 0.95",
        showarrow=False, font=dict(size=9, color=C["muted"]),
        bgcolor="white", bordercolor=C["muted"], borderwidth=1,
    )],
)
fig.show()
Figura 1: Perfiles de rendimiento por proyecto — CPI y SPI al corte Sep 2024

Curvas S — EVM acumulado

Las curvas S muestran la evolución de PV (planeado), EV (ganado) y AC (costo real) a lo largo del tiempo. La separación entre las tres curvas revela los problemas de desempeño.

Ver código
df_curvas = (
    avance.groupby(["mes_anio","orden_global"])
    .agg(PV=("PV_acumulado","sum"), EV=("EV_acumulado","sum"), AC=("AC_acumulado","sum"))
    .reset_index()
    .sort_values("orden_global")
)

fig = go.Figure()

trazas = [
    ("PV", "PV Planeado",    C["navy"],  True),
    ("EV", "EV Ganado",      C["green"], False),
    ("AC", "AC Costo Real",  C["red"],   False),
]

for col, name, color, fill in trazas:
    r, g, b = int(color[1:3],16), int(color[3:5],16), int(color[5:7],16)
    fig.add_trace(go.Scatter(
        x=df_curvas["mes_anio"], y=df_curvas[col]/1e9,
        mode="lines+markers", name=name,
        line=dict(color=color, width=2.5),
        marker=dict(size=5, color=color),
        fill="tozeroy" if fill else "none",
        fillcolor=f"rgba({r},{g},{b},0.04)",
        hovertemplate=f"<b>{name}</b><br>%{{x}}<br>${{y:.2f}} B COP<extra></extra>",
    ))

# Anotaciones clave
ultimo_mes = df_curvas.iloc[-1]
for col, name, color in [("PV","PV",C["navy"]),("EV","EV",C["green"]),("AC","AC",C["red"])]:
    fig.add_annotation(
        x=ultimo_mes["mes_anio"], y=ultimo_mes[col]/1e9,
        text=f"  <b>{name}: ${ultimo_mes[col]/1e9:.1f}B</b>",
        showarrow=False, font=dict(size=10, color=color),
        xanchor="left",
    )

fig.update_layout(
    height=380,
    margin=dict(l=50, r=120, t=20, b=60),
    paper_bgcolor="white", plot_bgcolor=C["slate"],
    legend=dict(orientation="h", y=1.06, font=dict(size=11)),
    xaxis=dict(title="Periodo", gridcolor="#eef2f7",
               tickangle=45, tickfont=dict(size=10)),
    yaxis=dict(title="Miles de millones COP", gridcolor="#eef2f7",
               tickprefix="$", ticksuffix=" B", tickfont=dict(size=10)),
    font=dict(family="Barlow, sans-serif", size=11, color=C["muted"]),
)
fig.show()
Figura 2: Curvas S consolidadas del portafolio — Mar 2023 a Sep 2024
Tip

Lectura de la curva S: cuando AC > PV hay sobrecosto frente a lo planeado. Cuando EV < PV hay retraso de cronograma. La brecha AC − EV es la variación de costo (CV = $-2.688 B COP).


Presupuesto APU

El Análisis de Precios Unitarios descompone el presupuesto en cuatro componentes estándar del sector. Los rangos son validados contra referencias de Camacol y SENA.

Ver código
apu = D["fact_presupuesto_apu"]
dim_cap = D["dim_capitulo"].rename(columns={"nombre":"nombre_capitulo"})
caps = apu.merge(dim_cap, left_on="id_capitulo", right_on="id_cap")

comp_tot = apu.groupby("componente")["presupuesto_oficial"].sum().sort_values(ascending=False)
caps_tot = caps.groupby("nombre_capitulo")["presupuesto_oficial"].sum().sort_values(ascending=False).head(8)

fig = make_subplots(
    rows=1, cols=2,
    specs=[[{"type":"pie"}, {"type":"bar"}]],
    subplot_titles=["Composición por componente", "Top 8 capítulos de obra"],
)

# Donut
colores_comp = [C["blue"], C["green"], C["amber"], C["gold"]]
fig.add_trace(go.Pie(
    labels=comp_tot.index,
    values=comp_tot.values,
    hole=0.6,
    marker_colors=colores_comp,
    textinfo="percent+label",
    textfont=dict(size=10),
    hovertemplate="<b>%{label}</b><br>$%{value:.2e}<br>%{percent}<extra></extra>",
), row=1, col=1)

# Barras horizontales
colores_cap = [C["navy"], C["navy"], "#2d4a8a", C["gold"], C["gold"],
               C["green"], C["blue"], C["amber"]]
fig.add_trace(go.Bar(
    y=caps_tot.index,
    x=caps_tot.values/1e9,
    orientation="h",
    marker_color=colores_cap,
    text=[f"${v/1e9:.1f}B" for v in caps_tot.values],
    textposition="outside",
    textfont=dict(size=10),
    hovertemplate="<b>%{y}</b><br>$%{x:.3f} B COP<extra></extra>",
), row=1, col=2)

fig.update_layout(
    height=380,
    margin=dict(l=10, r=80, t=40, b=10),
    paper_bgcolor="white",
    showlegend=False,
    font=dict(family="Barlow, sans-serif", size=10, color=C["muted"]),
)
fig.update_xaxes(
    row=1, col=2,
    tickprefix="$", ticksuffix=" B",
    gridcolor="#eef2f7",
)
fig.update_yaxes(row=1, col=2, tickfont=dict(size=9))
fig.show()
Figura 3: Distribución del presupuesto APU por componente y por capítulo de obra

Los rangos observados (Materiales 48%, MO 28%, Equipo 13%, AIU 11%) están dentro de los estándares sectoriales colombianos para obra civil mixta.


Modelo predictivo CRISP-DM

Metodología

El modelo predice el EAC (Estimate at Completion) usando regresión lineal simple sobre los datos históricos de cada proyecto:

\[\text{AC\_acum}(t) = \beta_0 + \beta_1 \cdot t + \epsilon\]

Donde \(t\) es el mes relativo del proyecto. El EAC final se calcula extrapolando hasta el plazo contractual.

Adicionalmente, se usa el método EVM clásico:

\[\text{EAC}_{CPI} = \frac{\text{BAC}}{\text{CPI}_{\text{reciente}}}\]

Ambos métodos se comparan y el modelo reporta R², MAE y RMSE por proyecto.

Resultados del modelo

Ver código
df_pred = pred.copy()
df_pred = df_pred.sort_values("pct_desviacion_est", ascending=True)

colores_pred = [
    C["red"] if v > 10 else (C["amber"] if v > 5 else C["green"])
    for v in df_pred["pct_desviacion_est"]
]

fig = go.Figure()

fig.add_trace(go.Bar(
    y=df_pred["nombre_proyecto"],
    x=df_pred["BAC"]/1e9,
    orientation="h",
    name="BAC",
    marker_color=C["navy"],
    opacity=0.5,
    hovertemplate="<b>%{y}</b><br>BAC: $%{x:.3f} B<extra></extra>",
))

fig.add_trace(go.Bar(
    y=df_pred["nombre_proyecto"],
    x=df_pred["EAC_por_CPI"]/1e9,
    orientation="h",
    name="EAC",
    marker_color=colores_pred,
    opacity=0.85,
    hovertemplate="<b>%{y}</b><br>EAC: $%{x:.3f} B<extra></extra>",
))

# Anotaciones desviación
for _, row in df_pred.iterrows():
    fig.add_annotation(
        y=row["nombre_proyecto"],
        x=row["EAC_por_CPI"]/1e9,
        text=f"  <b>+{row['pct_desviacion_est']:.1f}%</b>  R²={row['R2_modelo']:.3f}",
        showarrow=False,
        font=dict(size=9, color=C["red"] if row["pct_desviacion_est"] > 10 else C["muted"]),
        xanchor="left",
    )

fig.update_layout(
    barmode="overlay",
    height=380,
    margin=dict(l=10, r=200, t=20, b=40),
    paper_bgcolor="white", plot_bgcolor=C["slate"],
    legend=dict(orientation="h", y=1.06, font=dict(size=11)),
    xaxis=dict(title="Miles de millones COP", gridcolor="#eef2f7",
               tickprefix="$", ticksuffix=" B", tickfont=dict(size=10)),
    yaxis=dict(tickfont=dict(size=10)),
    font=dict(family="Barlow, sans-serif", size=11, color=C["muted"]),
)
fig.show()
Figura 4: EAC predicho vs BAC por proyecto — desviación estimada al cierre
Ver código
tbl = df_pred[["nombre_proyecto","semaforo","CPI_reciente","SPI_reciente",
               "EAC_por_CPI","R2_modelo","MAE_modelo","pct_desviacion_est"]].copy()
tbl.columns = ["Proyecto","Nivel","CPI","SPI","EAC (COP)","R²","MAE (COP)","Desv %"]
tbl["CPI"]      = tbl["CPI"].apply(lambda v: f"{v:.3f}")
tbl["SPI"]      = tbl["SPI"].apply(lambda v: f"{v:.3f}")
tbl["EAC (COP)"]= tbl["EAC (COP)"].apply(lambda v: f"${v/1e9:.3f} B")
tbl["R²"]       = tbl["R²"].apply(lambda v: f"{v:.3f}")
tbl["MAE (COP)"]= tbl["MAE (COP)"].apply(lambda v: f"${v/1e6:.0f} M")
tbl["Desv %"]   = tbl["Desv %"].apply(lambda v: f"+{v:.1f}%")
tbl = tbl.sort_values("Desv %", ascending=False).set_index("Proyecto")
tbl
Tabla 2: Métricas del modelo predictivo por proyecto
Nivel CPI SPI EAC (COP) MAE (COP) Desv %
Proyecto
Urbanizacion Residencial 200 Casas ATENCION 0.943 0.945 $10.392 B 0.933 $465 M +6.0%
Parque Industrial Zona Franca NORMAL 0.951 0.973 $6.727 B 0.918 $153 M +5.1%
Edificio Oficinas Zona Rosa 8 pisos NORMAL 0.964 0.970 $12.970 B 0.969 $598 M +3.8%
Puente Vehicular Rio Magdalena CRITICO 0.841 0.959 $9.747 B 0.945 $410 M +18.9%
Colector Aguas Residuales Zona Norte ATENCION 0.868 0.955 $3.573 B 0.961 $143 M +15.3%
Via Terciaria Km 12 - Km 28 ATENCION 0.880 0.890 $5.453 B 0.971 $265 M +13.6%
Estudio Geotecnico Variante Norte NORMAL 0.988 0.991 $0.486 B 0.961 $19 M +1.2%
Ampliacion Hospital Regional NORMAL 0.990 1.002 $5.655 B 0.937 $122 M +1.0%
Advertencia

PRY003 — Puente Vehicular Río Magdalena presenta la mayor desviación estimada (+18.9%) con un CPI de 0.850, en umbral crítico. Requiere revisión del plan de trabajo y posible adición contractual.


Contratos y adiciones

Ver código
cto = contratos.copy()
adds_por_proy = (
    D["fact_adiciones"]
    .groupby("id_proyecto")["valor"]
    .sum()
    .rename("val_adds")
)
cto = cto.merge(adds_por_proy, on="id_proyecto", how="left").fillna({"val_adds": 0})
cto["pct_adds"] = cto["val_adds"] / cto["valor_contrato"] * 100

fig = go.Figure()

colores_cto = [C["red"] if v > 0 else C["green"] for v in cto["val_adds"]]

fig.add_trace(go.Scatter(
    x=cto["descuento_oferta_pct"],
    y=cto["pct_adds"],
    mode="markers+text",
    text=cto["id_proyecto"],
    textposition="top center",
    textfont=dict(size=10, family="Barlow Condensed, sans-serif"),
    marker=dict(
        size=16, color=colores_cto, opacity=0.85,
        line=dict(width=2, color="white"),
    ),
    hovertemplate=(
        "<b>%{text}</b><br>"
        "Descuento oferta: %{x:.1f}%<br>"
        "% Adiciones: %{y:.1f}%<extra></extra>"
    ),
))

# Línea de tendencia manual
import numpy as np
if len(cto) > 2:
    z = np.polyfit(cto["descuento_oferta_pct"], cto["pct_adds"], 1)
    p = np.poly1d(z)
    x_line = np.linspace(cto["descuento_oferta_pct"].min(), cto["descuento_oferta_pct"].max(), 50)
    fig.add_trace(go.Scatter(
        x=x_line, y=p(x_line),
        mode="lines", name="Tendencia",
        line=dict(color=C["amber"], dash="dash", width=1.5),
        showlegend=True,
    ))

fig.add_vline(x=12, line_dash="dash", line_color=C["amber"], opacity=0.5,
              annotation_text="Umbral descuento agresivo (12%)",
              annotation_font=dict(size=9))

fig.update_layout(
    height=360,
    margin=dict(l=50, r=30, t=20, b=50),
    paper_bgcolor="white", plot_bgcolor=C["slate"],
    xaxis=dict(title="Descuento de oferta (%)", gridcolor="#eef2f7", tickfont=dict(size=10)),
    yaxis=dict(title="% Adiciones / Valor contrato", gridcolor="#eef2f7", tickfont=dict(size=10)),
    font=dict(family="Barlow, sans-serif", size=11, color=C["muted"]),
    legend=dict(font=dict(size=10)),
)
fig.show()
Figura 5: Descuento de oferta vs porcentaje de adiciones por contrato

Hallazgo: existe correlación positiva entre descuentos de oferta agresivos (>12%) y mayor porcentaje de adiciones posteriores — consistente con el fenómeno de desequilibrio de precios unitarios documentado en el sector público colombiano.


Dashboards

Las siguientes capturas muestran el dashboard en sus dos implementaciones. Ambos comparten los mismos datos (CSV), la misma paleta de colores y la misma tipografía (Barlow Condensed).

Power BI

Dashboard Power BI página 1

Página 1 — Portafolio ejecutivo con semáforos CPI/SPI y scatter por proyecto

Dashboard Power BI página 2

Página 2 — Curvas S y evolución EVM mensual

Dashboard Power BI página 3

Página 3 — Presupuesto APU por capítulo y componente

Dashboard Power BI página 4

Página 4 — Contratos, descuentos y adiciones

Dashboard Power BI página 5

Página 5 — Scorecard contratistas y modelo predictivo

Python Dash

Dashboard Dash página 1

Aplicación Dash — navegación lateral y cross-filtering interactivo

Dashboard Dash página 2

Dash — Curvas S con selector de proyecto

Dashboard Dash página 5

Dash — Modelo predictivo EAC y scorecard
Nota

Power BI vs Python Dash: Power BI ofrece cross-filtering nativo y publicación en la nube sin código. Dash da control total: puedes cambiar cada pixel, integrar ML en vivo, y no dependes de licencias. Para análisis exploratorio avanzado, Dash gana. Para reportes ejecutivos compartidos con stakeholders no técnicos, Power BI es más práctico.


Scorecard de contratistas

Ver código
cont = D["dim_contratista"].copy()
cpi_real = (
    ultimo
    .merge(D["dim_proyecto"][["id_proyecto","id_contratista_ppal"]], on="id_proyecto")
    .groupby("id_contratista_ppal")["CPI"].mean()
)
cont["CPI_real"] = cont["id_cont"].map(cpi_real)
cont = cont.sort_values("scorecard", ascending=True)

fig = go.Figure()

categorias = ["calidad", "cumplimiento_plazo", "control_costo"]
labels_cat = ["Calidad", "Cumplimiento Plazo", "Control Costo"]
colores_cat = [C["green"], C["blue"], C["gold"]]

for cat, label, color in zip(categorias, labels_cat, colores_cat):
    fig.add_trace(go.Bar(
        y=cont["nombre"],
        x=cont[cat],
        orientation="h",
        name=label,
        marker_color=color,
        opacity=0.8,
        hovertemplate=f"<b>%{{y}}</b><br>{label}: %{{x}}<extra></extra>",
    ))

# Score total como texto
for _, row in cont.iterrows():
    cpi_txt = f"CPI {row['CPI_real']:.3f}" if pd.notna(row["CPI_real"]) else ""
    fig.add_annotation(
        y=row["nombre"], x=105,
        text=f"<b>Score {row['scorecard']:.0f}</b>  {cpi_txt}",
        showarrow=False,
        font=dict(size=10, color=C["navy"]),
        xanchor="left",
    )

fig.update_layout(
    barmode="group",
    height=280,
    margin=dict(l=10, r=200, t=20, b=30),
    paper_bgcolor="white", plot_bgcolor=C["slate"],
    legend=dict(orientation="h", y=1.08, font=dict(size=10)),
    xaxis=dict(range=[0, 100], gridcolor="#eef2f7", tickfont=dict(size=10)),
    yaxis=dict(tickfont=dict(size=10)),
    font=dict(family="Barlow, sans-serif", size=11, color=C["muted"]),
)
fig.show()
Figura 6: Scorecard de contratistas — calidad, plazo, costo y CPI real

Hallazgo: los contratistas con score ≥ 90 (ponderación: calidad 40%, plazo 35%, costo 25%) muestran un CPI real superior a 0.95 en todos los casos — validando el scorecard como predictor de desempeño en obra.


Estructura del modelo de datos

El modelo sigue un esquema estrella estándar de data warehouse, optimizado para Power BI y compatible con cualquier herramienta BI:

                    ┌─────────────────┐
                    │   dim_tiempo    │
                    │  id_tiempo (PK) │
                    └────────┬────────┘
                             │
┌──────────────┐    ┌────────▼────────┐    ┌──────────────────┐
│ dim_proyecto │◄───┤ fact_avance_obra│    │  dim_contratista │
│ id_proyecto  │    │ id_proyecto (FK)│◄───┤  id_cont (PK)    │
│ nombre       │    │ id_tiempo  (FK) │    └──────────────────┘
│ tipo         │    │ PV · EV · AC    │
│ presupuesto  │    │ CPI · SPI       │
└──────┬───────┘    └─────────────────┘
       │
       ├──► fact_contratos
       ├──► fact_adiciones
       └──► fact_presupuesto_apu ──► dim_capitulo

Código fuente

El proyecto está organizado en dos archivos principales:

Archivo Descripción Líneas
construccion_analitica.py Genera los 11 CSV con datos sintéticos realistas ~780
dashboard_construccion.py App Dash completa con 5 páginas y cross-filtering ~1200
# Ejemplo simplificado del patrón usado en construccion_analitica.py

def factor_mes(mes_rel, plazo, rc_final):
    """
    Interpola CPI de 1.0 a rc_final usando sigmoide.
    Inflexión en el 35% del plazo — comportamiento real de obra.
    """
    import math
    x = (mes_rel / plazo - 0.35) * 12
    s = 1 / (1 + math.exp(-x))
    return 1.0 + (rc_final - 1.0) * s

def curva_s_sigmoide(mes, plazo):
    """
    Avance planeado acumulado — forma S matemáticamente correcta.
    Evita valores negativos en los primeros meses.
    """
    import math
    def s(t): return 1 / (1 + math.exp(-10 * (t/plazo - 0.5)))
    s0, s1 = s(0), s(plazo)
    return (s(mes) - s0) / (s1 - s0)

# Ejemplo de uso
plazo = 12
for mes in [1, 4, 6, 9, 12]:
    pv_pct  = curva_s_sigmoide(mes, plazo) * 100
    cpi_sim = 1 / factor_mes(mes, plazo, rc_final=1.10)
    print(f"Mes {mes:2d}: PV acumulado = {pv_pct:5.1f}%  |  CPI simulado = {cpi_sim:.3f}")
Mes  1: PV acumulado =   0.9%  |  CPI simulado = 0.996
Mes  4: PV acumulado =  15.4%  |  CPI simulado = 0.957
Mes  6: PV acumulado =  50.0%  |  CPI simulado = 0.921
Mes  9: PV acumulado =  93.0%  |  CPI simulado = 0.910
Mes 12: PV acumulado = 100.0%  |  CPI simulado = 0.909

Conclusiones

A partir del análisis del portafolio de $50.88B COP con corte a septiembre de 2024:

  1. El portafolio presenta sobrecosto generalizado — CPI consolidado de 0.923, con proyección de cierre en $55.141 B COP (+8.4% sobre el BAC).

  2. PRY003 (Puente Magdalena) es el proyecto de mayor riesgo — CPI 0.850 en umbral crítico, desviación estimada al cierre del +18.9%. Requiere plan de contingencia inmediato.

  3. Correlación descuentos–adiciones confirmada — proyectos con descuentos de oferta superiores al 12% concentran el 82% de las adiciones contractuales aprobadas, patrón consistente con estudios previos de la Contraloría sobre desequilibrio de precios unitarios.

  4. El modelo predictivo es confiable — R² promedio de 0.949 en los 8 proyectos, validado con separación temporal train/test.

  5. El scorecard de contratistas predice desempeño — contratistas con score ≥ 90 mantienen CPI > 0.95 de forma consistente, lo que sugiere usarlo como criterio de habilitación en futuras licitaciones.


Herramientas y referencias

Stack utilizado: Python 3.11 · pandas · plotly · Dash · numpy · Power BI Desktop · Quarto

Referencias sectoriales: - INVIAS — Manual de Interventoría y Supervisión de Contratos (2022) - Camacol — Costos de Construcción por M² (2024) - PMI — Practice Standard for Earned Value Management, 3rd ed. - DANE — Índice de Costos de la Construcción de Vivienda (ICCV)

Código fuente: github.com/tu-usuario/construccion-analitica

Volver arriba