Grafote: ¿Cuáles son mis notas menos confiables?#

Este grafo se encarga de analizar las notas asignadas por parte de las materias hacia estudiantes con el objetivo de identificar las materias injustas, notas confiables, mejores estudiantes, y más. Para esto se utiliza el algoritmo REV2.

REV2 tiene como objetivo encontrar usuarios fraudulentos en plataformas que tengan ratings hacia ciertos productos.

Bajo el contexto de FIUBA, podríamos pensar a las materias como “usuarios”, a los estudiantes como “productos”, y las notas de final como “calificaciones” con el objetivo de aplicar REV2 con algunos supuestos:

  • Un estudiante mantiene un cierto desempeño desde su inicio a fin de carrera.

  • La forma de evaluar de cada materia se mantiene consistente más allá de cátedras o profesores.

No esperamos encontrar materias fraudulentas, pero de cualquier forma podemos analizar los valores F, G y R para materias, usuarios y notas que devuelve el algoritmo.

¿Cómo es el grafo?#

El grafo va a ser bipartito

  • Nodos: Materias por un lado, estudiantes por el otro

  • Aristas: Un estudiante aprobó una materia, la arista representa la nota y está direccionada de la materia hacia el estudiante (materia valoró a este estudiante con esta nota)

  • Peso: Nota de final

A diferencia de los otros grafos, este utiliza la información de todas las carreras en vez de limitarse a solo una.

Preprocesamiento#

import networkx as nx
import pandas as pd
import numpy as np
import scipy as sp
from tqdm.notebook import tqdm

import utils

Obtenemos todos los planes de carrera de FIUBA, para tener los nombres de materias al analizar los resultados

df_planes = []
for plan in utils.PLANES:
    df_plan = pd.read_json(utils.plan_estudios(plan))
    df_plan['Carrera'] = plan
    df_planes.append(df_plan)
df_planes = pd.concat(df_planes)
df_planes = df_planes.rename(columns={'materia': 'materia_nombre', 'id': 'materia_id'})
df_planes.sample(3)
materia_id materia_nombre creditos categoria level correlativas requiere requiereCBC Carrera
68 92.12 Industrias Textiles 4 Materias Electivas NaN 91.12-92.01 NaN NaN industrial
80 86.56 Procesamiento de Imágenes 6 Multiples Orientaciones NaN 86.51-86.55 NaN NaN electronica
28 97.01 Higiene y Seguridad Industrial 4 Materias Obligatorias 5.0 NaN 118.0 1.0 industrial

Y el grafo con las notas en sí

df = pd.read_pickle('fiuba-map-data-all.pickle')
display(df.sample(3))
df.shape
Padron Carrera Orientacion Final de Carrera materia_id materia_nota materia_cuatrimestre checkboxes optativas aplazos
97671 98133 industrial NaN tpp 82.01 4.0 NaN NaN NaN NaN
15692 100566 informatica Sistemas Distribuidos tpp 75.52 -2.0 2024.0 NaN NaN NaN
76249 102429 industrial NaN tpp 81.01 8.0 2017.0 NaN NaN NaN
(104335, 10)
# join con los planes, para tenerlo
df = pd.merge(df, df_planes, on=['materia_id', 'Carrera'])

# filtros de columnas relevantes
df = df[['Padron', 'materia_id', 'materia_nota', 'materia_nombre', 'Carrera']]
df.head()
Padron materia_id materia_nota materia_nombre Carrera
0 Mica CBC 0.0 Ciclo Básico Común quimica
1 106023 CBC 0.0 Ciclo Básico Común quimica
2 103928 CBC 0.0 Ciclo Básico Común quimica
3 103413 CBC 0.0 Ciclo Básico Común quimica
4 106253 CBC 0.0 Ciclo Básico Común quimica
# Se toman solo cursadas aprobadas
df_notas = df[df['materia_nota'] >= 4].copy()

# Se ignora el CBC
df_notas = df_notas[~df_notas['materia_id'].str.contains('CBC')]

df_notas['nota_mediana'] = df_notas.groupby(['Padron', 'Carrera'])['materia_nota'].transform('median')
df_notas['nota_promedio'] = df_notas.groupby(['Padron', 'Carrera'])['materia_nota'].transform('mean')
df_notas['nota_diferentes'] = df_notas.groupby(['Padron', 'Carrera'])['materia_nota'].transform('nunique')

# Para reducir ruido, se eliminan mapas donde las notas son casi "todas iguales"
df_notas = df_notas[df_notas['nota_diferentes'] >= 4]
df_notas = df_notas[df_notas['nota_mediana'] > 4.5]

df_notas.shape
(44391, 8)
# Normalizar notas de [-1 a 1]
df_notas['materia_nota_norm'] = (df_notas['materia_nota'] - 7) / 3
df_notas['materia_nota_norm'].value_counts()
 0.000000    9846
 0.333333    9490
-0.333333    7402
 0.666667    6267
-0.666667    4507
-1.000000    3993
 1.000000    2886
Name: materia_nota_norm, dtype: int64
# Se agregan columnas de cantidades, para luego filtrar más adelante
df_notas['cant_materias'] = df_notas.groupby(['Padron'])['materia_id'].transform('count')
df_notas['cant_padrones'] = df_notas.groupby(['materia_id'])['Padron'].transform('count')

Algoritmo REV2#

Se plantea el algoritmo genérico de REV2 para luego aplicarlo al grafo armado.

def rev2(
    df,
    col_in,
    col_out,
    col_rating,
    gamma_1=0.5,
    gamma_2=0.5,
    n_iter=20,
):
    """Algoritmo REV2. Dado un dataframe con entradas, salidas y ratings
    denotados por los nombres de columna, devuelve series para aplicar los
    valores de F, G y R.
    """
    assert df[col_rating].min() == -1 and df[col_rating].max() == 1, "Rating no fue normalizado a [-1, 1]"

    ratings = nx.from_pandas_edgelist(
        df,
        create_using=nx.DiGraph,
        source=col_in,
        target=col_out,
        edge_attr=[col_rating]
    )
    
    F = {}
    G = {}

    for val_in, val_out in tqdm(ratings.edges(), 'Inicializando estructuras'):
        F[val_in] = 1
        G[val_out] = 1
        ratings[val_in][val_out]['R'] = 1

    for i in tqdm(range(n_iter), 'Realizando iteraciones F/G/R'):
        for val_out in G:
            s = 0
            n = 0
            for val_in in ratings.predecessors(val_out):
                s += ratings[val_in][val_out]['R'] * ratings[val_in][val_out][col_rating]
                n += 1
            G[val_out] = s / n
            assert -1 <= G[val_out] <= 1

        for val_in, val_out in ratings.edges():
            R_new = (gamma_1 * F[val_in] + gamma_2 * (1 - (abs(ratings[val_in][val_out][col_rating] - G[val_out]) / 2))) / (gamma_1 + gamma_2)
            ratings[val_in][val_out]['R'] = R_new
            assert 0 <= R_new <= 1

        for val_in in F:
            s = 0
            for val_out in ratings[val_in]:
                s += ratings[val_in][val_out]['R']
            F[val_in] = s / len(ratings[val_in])
            assert 0 <= F[val_in] <= 1

    series_F = df[col_in].apply(lambda x: F[x])
    series_G = df[col_out].apply(lambda x: G[x])
    series_R = df[[col_in, col_out]].apply(lambda x: ratings[x[col_in]][x[col_out]]['R'], axis=1)

    return series_F, series_G, series_R, ratings
series_F, series_G, series_R, G = rev2(
    df_notas,
    'materia_id',
    'Padron',
    'materia_nota_norm',
)
df_notas['F'] = series_F
df_notas['G'] = series_G
df_notas['R'] = series_R
# Veamos (un sample de) el grafo sobre el cual se corrió REV2
import matplotlib.pyplot as plt
import utils
from config import PADRON
import random

# Subgrafo => 3 materias troncales y 3 padrones al azar
materias = ["62.01", "61.03", "61.08"]
random_edges = random.choices(list(filter(lambda e: any([m in e for m in materias]), G.edges)), k=3)
random_padrones = [p for m, p in random_edges]

nodes = [*random_padrones, *materias]
subgraph = nx.subgraph(G, nodes)

plt.figure(figsize=(15,5))
plt.title(f"Sample of a {G}")
nx.draw_networkx(subgraph,
                 pos=nx.bipartite_layout(subgraph, nx.bipartite.sets(subgraph)[0]))
_images/grafote_15_0.png

Análisis de resultados#

Para el análisis de materias, se consideran solo aquellas que tengan un mínimo de estudiantes aprobados. Lo mismo para el análisis de estudiantes

MATERIAS_APROBADAS = 7
ESTUDIANTES_APROBADOS = 15
# Para ver los nombres completos de los nombres de materias
pd.set_option('display.max_colwidth', None)

Fairness#

Bajo este contexto, una materia injusta puede dar nota baja a “buenos estudiantes” y/o nota alta a “malos estudiantes”.

(
    df_notas[(df_notas['cant_padrones'] >= ESTUDIANTES_APROBADOS)]
        [['materia_id', 'materia_nombre', 'F']]
        .drop_duplicates()
        .sort_values('F', ascending=True)
        .head(15)
)
materia_id materia_nombre F
94046 76.27 Control Estadístico de Procesos 0.646846
90722 64.13 Estabilidad III B 0.651707
99560 91.25 Estadística Aplicada III 0.655873
90993 71.01 Introducción a la Economía y Organización de la Empresa 0.658874
81975 91.09 Economía 0.660895
79428 91.19 Introducción a la Economía y Organización de la Empresa 0.673984
101542 88.07 Tránsito 0.683709
100355 86.57 Acústica 0.691098
67373 91.31 Investigación Operativa II 0.694407
100426 62.07 Mecánica II 0.701697
14868 81.11 Matemática Discreta 0.702132
64591 91.07 Investigación Operativa I 0.708240
92740 76.48 Evaluación de Propiedades Físicas 0.710065
73354 63.14 Química Orgánica 0.716513
100987 70.02 Geometría Descriptiva 0.716515

Las materias justas suelen asignar notas conservadoras que tienden de 6 a 8.

(
    df_notas[(df_notas['cant_padrones'] >= ESTUDIANTES_APROBADOS)]
        [['materia_id', 'materia_nombre', 'F']]
        .drop_duplicates()
        .sort_values('F', ascending=False)
        .head(15)
)
materia_id materia_nombre F
82360 94.01 Hormigón I 0.896680
1083 85.02 Electrotecnia 0.887611
99153 75.69 Sistemas Automáticos de Diagnóstico y Detección Fallas II 0.881780
86007 88.13 Puertos y Vías Navegables A 0.880578
66885 92.06 Automatización Industrial y Robótica 0.877743
92104 94.13 Patología de la Construcción 0.877140
85080 89.11 Ingeniería Sanitaria I 0.875276
98868 67.30 Combustión 0.874913
91544 67.50 Materiales Ferrosos y sus Aplicaciones 0.872527
93246 76.52 Operaciones Unitarias de Transferencia de Materia 0.870980
24819 75.10 Técnicas de Diseño 0.865805
86481 91.16 Legislación y Ejercicio Profesional de la Ingeniería Civil 0.864699
50306 83.02 Química Aplicada 0.864635
1994 85.20 Energías Renovables 0.863711
100764 88.03 Puertos y Vías Navegables B 0.863584

Goodness#

Nota que una materia “confiable” le podría dar a tal estudiante. No es lo mismo que el promedio pero existe una correlación.

df_notas['G_norm'] = df_notas['G'] * 3 + 7
(
    df_notas[(df_notas['cant_materias'] >= MATERIAS_APROBADAS)]
        [['Padron', 'Carrera', 'G_norm', 'nota_promedio']]
        .drop_duplicates()
        .sort_values('G_norm', ascending=True)
        .head(15)
)
Padron Carrera G_norm nota_promedio
41586 103444 industrial 5.380374 5.000000
42217 97478 industrial 5.410048 5.023256
71647 102950 quimica 5.520623 5.187500
41819 108112 industrial 5.575080 5.250000
5262 95536 informatica 5.592565 5.210526
41822 104040 industrial 5.644006 5.333333
30531 101309 civil 5.684993 5.384615
42535 99651 industrial 5.686176 5.388889
461 pelepelepele electricista 5.695637 5.421053
462 gules electricista 5.695637 5.421053
463 morsin electricista 5.695637 5.421053
41885 104736 industrial 5.735251 5.428571
41817 106346 industrial 5.761698 5.428571
41739 103464 industrial 5.777393 5.444444
408 95568 electricista 5.779901 5.478261
(
    df_notas[(df_notas['cant_materias'] >= MATERIAS_APROBADAS)]
        [['Padron', 'Carrera', 'G_norm', 'nota_promedio']]
        .drop_duplicates()
        .sort_values('G_norm', ascending=False)
        .head(15)
)
Padron Carrera G_norm nota_promedio
71729 100623 quimica 8.866571 9.280000
30407 105895 civil 8.728318 9.076923
30394 109522 civil 8.715727 9.125000
5335 105646 informatica 8.708965 9.083333
5666 105836 informatica 8.617090 8.950000
5380 108814 informatica 8.610925 9.000000
36144 110456 electronica 8.570352 8.931034
36326 103839 electronica 8.554069 8.903226
5263 105798 informatica 8.549445 8.888889
94580 0 alimentos 8.548754 8.941176
71526 0 quimica 8.548754 8.833333
42212 4383567 industrial 8.532443 8.850000
5627 102145 informatica 8.516730 8.823529
5264 102749 informatica 8.491980 8.827586
30388 105536 civil 8.476696 8.760000

Reliability#

Una nota poco confiable puede estar en dos extremos: una nota alta a un estudiante con un desempeño general bajo, o una nota baja a un estudiante con un buen desempeño

(
    df_notas[
        (df_notas['cant_padrones'] >= ESTUDIANTES_APROBADOS)
        & (df_notas['cant_materias'] >= MATERIAS_APROBADAS)
    ][['Padron', 'Carrera', 'materia_id', 'materia_nombre', 'materia_nota', 'R', 'G_norm']]
        .drop_duplicates()
        .sort_values('R', ascending=True)
        .head(15)
)
Padron Carrera materia_id materia_nombre materia_nota R G_norm
82291 105370 civil 91.09 Economía 4.0 0.499946 7.966017
79501 103221 electronica 91.19 Introducción a la Economía y Organización de la Empresa 4.0 0.507515 7.953721
91054 102538 mecanica 71.01 Introducción a la Economía y Organización de la Empresa 4.0 0.507557 7.862557
81992 104497 civil 91.09 Economía 4.0 0.511299 7.829780
91023 103277 mecanica 71.01 Introducción a la Economía y Organización de la Empresa 4.0 0.517744 7.740315
82260 103365 civil 91.09 Economía 4.0 0.517816 7.751578
61500 100735 industrial 97.01 Higiene y Seguridad Industrial 10.0 0.518093 5.800418
91047 94286 mecanica 71.01 Introducción a la Economía y Organización de la Empresa 4.0 0.518956 7.725769
82155 107756 civil 91.09 Economía 4.0 0.520227 7.722652
91038 102539 mecanica 71.01 Introducción a la Economía y Organización de la Empresa 4.0 0.520892 7.702543
94085 87699 quimica 76.27 Control Estadístico de Procesos 10.0 0.521119 6.372350
20012 103371 informatica 61.07 Matemática Discreta 4.0 0.521172 8.108515
79494 102176 electronica 91.19 Introducción a la Economía y Organización de la Empresa 4.0 0.522984 7.768100
860 91218 electricista 81.03 Probabilidad y Estadística A 4.0 0.523975 8.093892
33764 103789 civil 81.03 Probabilidad y Estadística A 4.0 0.525280 8.078230

Una nota confiable es la más esperable para asignarle al estudiante según su valor de G.

(
    df_notas[
        (df_notas['cant_padrones'] >= ESTUDIANTES_APROBADOS)
        & (df_notas['cant_materias'] >= MATERIAS_APROBADAS)
    ][['Padron', 'Carrera', 'materia_id', 'materia_nombre', 'materia_nota', 'R', 'G_norm']]
        .drop_duplicates()
        .sort_values('R', ascending=False)
        .head(15)
)
Padron Carrera materia_id materia_nombre materia_nota R G_norm
82604 100726 civil 94.01 Hormigón I 7.0 0.947925 6.995015
82412 100291 civil 94.01 Hormigón I 6.0 0.947688 6.007822
82618 97875 civil 94.01 Hormigón I 6.0 0.947657 5.991802
82464 104279 civil 94.01 Hormigón I 8.0 0.947465 7.989495
82387 106102 civil 94.01 Hormigón I 8.0 0.946584 8.021076
82578 102187 civil 94.01 Hormigón I 8.0 0.946132 7.973500
82607 100875 civil 94.01 Hormigón I 7.0 0.945954 7.028638
82615 94275 civil 94.01 Hormigón I 7.0 0.945846 7.029928
82645 103213 civil 94.01 Hormigón I 7.0 0.945705 6.968375
82606 105370 civil 94.01 Hormigón I 8.0 0.945508 7.966017
82641 100531 civil 94.01 Hormigón I 7.0 0.945057 6.960601
82438 103332 civil 94.01 Hormigón I 7.0 0.944874 7.041593
82655 101337 civil 94.01 Hormigón I 6.0 0.944285 6.048665
82624 102632 civil 94.01 Hormigón I 7.0 0.943380 6.940482
82381 102246 civil 94.01 Hormigón I 7.0 0.942920 6.934956

Reliability de un padrón particular#

Considerando un único padrón podemos ver sus notas “menos confiable” considerando el cuantil 0.1 de R

(
    df_notas[
        (df_notas['Padron'] == utils.PADRON)
        & (df_notas['R'] <= df_notas['R'].quantile(0.10))
    ][['Padron', 'Carrera', 'materia_id', 'materia_nombre', 'materia_nota', 'R', 'G_norm']]
        .drop_duplicates()
        .sort_values('R', ascending=True)
        .head(5)
)
Padron Carrera materia_id materia_nombre materia_nota R G_norm
19902 100029 informatica 61.07 Matemática Discreta 4.0 0.597084 7.197567
20178 100029 informatica 75.31 Teoría de Lenguaje 10.0 0.626889 7.197567
5320 100029 informatica 62.01 Física I A 4.0 0.636554 7.197567
28005 100029 informatica 66.02 Laboratorio 4.0 0.654815 7.197567
18730 100029 informatica 75.08 Sistemas Operativos 10.0 0.658460 7.197567