Grafón: ¿Qué electivas me conviene cursar?#

Este grafo se encarga de analizar “gente que haya tenido experiencias facultativas similares”, sin importar en qué año o cuatrimestre fue.

La idea es que si me fue parecido en varias materias a alguna otra persona, y esa persona cursó alguna electiva que yo no, entonces esa materia es una buena candidata para mí.

Por ejemplo: por más que Juan se haya recibido el año pasado, es una persona que se parece mucho a mí porque nos sacamos las mismas notas en muchas materias. Probablemente las electivas que Juan eligió, me sirvan a mí como guía.

¿Cómo es el grafo?#

El grafo analizado va a ser un grafo simple, y no un multigrafo: entre cada par de alumnos solo puede haber una arista. Manejamos la cardinalidad en el peso, en vez de tener múltiples aristas.

  • Nodos: alumnos

  • Aristas: conectar dos alumnos que hayan cursado la misma materia y “les fue parecido”.

    • A los dos nos fue muy bien (nos sacamos entre 8 y 10)

    • A los dos nos fue más o menos (nos sacamos un 6 o un 7)

    • A los dos nos fue mal (nos sacamos un 4 o un 5)

  • Peso: La inversa* de la cantidad de materias en donde somos similares

* Calculamos la inversa porque mientras más similares son, menor peso queremos que haya entre la arista, para que más cercanos estén

import pandas as pd
import utils

df = pd.read_pickle('fiuba-map-data.pickle')
df = df.dropna(axis=1, how='all')
display(df.sample(3))
df.shape
Padron Carrera Orientacion Final de Carrera materia_id materia_nota materia_cuatrimestre optativas aplazos
65236 999876 informatica Sistemas Distribuidos tesis 66.02 8.0 2020.0 NaN NaN
14118 0000 informatica Gestión Industrial de Sistemas tpp 75.07 6.0 2015.0 NaN NaN
14982 108111 informatica Sistemas Distribuidos NaN 75.43 -2.0 2024.0 NaN NaN
(21787, 9)
# Armamos las categorias de que les haya hido parecido a dos alumnos
categories = {
    4: 0,
    5: 0,
    6: 1,
    7: 1,
    8: 2,
    9: 2,
    10: 2
}

# Sacamos materias en final y a cursar
df_alumnos = df[df['materia_nota'] >= 4]

# Sacamos gente que no le pone la nota a su fiubamap y deja que se saco (casi) todos 4s directamente
df_alumnos['mediana'] = df_alumnos.groupby('Padron')['materia_nota'].transform('median')
df_alumnos = df_alumnos[df_alumnos['mediana'] > 5]

df_alumnos['nota_categoria'] = df_alumnos['materia_nota'].apply(lambda x: categories[x])

# Juntamos el grafo con si mismo para tener la similiritud entre cada par de padrones
df_simil_sinagg = utils.construir_df_pareando_padrones_por(df_alumnos, 'nota_categoria')

df_simil_sinagg = df_simil_sinagg[['materia_id', 'nota_categoria', 'Padron_x', 'materia_nota_x', 'Padron_y', 'materia_nota_y']]

# Unificar aristas ( con el objetivo de no tener pocos nodos y millones de aristas )
df_simil = df_simil_sinagg.groupby(['Padron_x', 'Padron_y']).agg(cant_materias_similares=('materia_id', 'count'))
df_simil = df_simil.reset_index()

df_simil['inv_cant_materias_similares'] = df_simil['cant_materias_similares'].max() - df_simil['cant_materias_similares'] + 1
df_simil[['Padron_x', 'Padron_y', 'cant_materias_similares', 'inv_cant_materias_similares']]


# Nota: Al no tener en cuenta el factor temporal en este grafo (en que cuatri cursó cada alumno), esta vez no se aplica el filtro de "sacar todos los alumnos que no setean el cuatrimestre"
# Ese filtro es considerable, por lo que tiene sentido que este grafo sea bastante mas grande que grafazo
display(df_simil.sample(3))
df_simil.shape
Padron_x Padron_y cant_materias_similares inv_cant_materias_similares
130951 105250 datatouilleesdelrojo 4 32
88093 103879 102340 3 33
311716 95512 99393 4 32
(355938, 4)
# Veamos los padrones más parecidos entre sí
df_simil.sort_values('cant_materias_similares', ascending=False).head(10)
Padron_x Padron_y cant_materias_similares inv_cant_materias_similares
13117 100687 99732 35 1
344280 99732 100687 35 1
341282 99616 99732 32 4
344849 99732 99616 32 4
40248 102145 100687 31 5
12594 100687 102145 31 5
43722 102192 102145 30 6
40300 102145 102192 30 6
11950 100680 101483 30 6
26079 101483 100680 30 6
import networkx as nx
import matplotlib.pyplot as plt

G = nx.from_pandas_edgelist(df_simil, 
                            source='Padron_x', 
                            target='Padron_y', 
                            edge_attr='inv_cant_materias_similares',
                            create_using=nx.Graph())

utils.stats(G)
utils.plot(G, edge_width=0.0005)
Graph with 642 nodes and 177969 edges
  El diámetro de la red: 3
  El grado promedio de la red: 554.42
  Puentes globales: []
_images/grafon_5_1.png

Comunidades#

Este análisis de comunidades que vamos a realizar no está basado en una hipótesis previa de homofilia o similitud entre los nodos, como lo visto en grafazo. En lugar de ello, el objetivo del análisis es explorar los patrones de conexión que existen en el grafo y agrupar los nodos en comunidades para tener un mejor entendimiento de los mismos.

Este enfoque exploratorio es útil cuando se tiene un conjunto de datos desconocido o no se cuenta con una hipótesis clara sobre los patrones de relación entre los nodos los (como se tenía en grafazo con respecto a las camadas). El análisis de comunidades puede ayudarnos a identificar grupos de nodos que presentan patrones similares de conexión, lo que puede llevar a la identificación de relaciones interesantes entre los nodos y a la formulación de nuevas hipótesis sobre el grafo.

A partir del modelado de grafon, vamos a poder identificar comunidades de alumnos que han tenido un desempeño similar en sus materias y que, por lo tanto, podrían compartir intereses académicos. Es por eso que tomaremos esto como base para poder identificar qué cursaron las personas similares a mi con el fin de otorgar un listado de recomendaciones.

from networkx.algorithms import community

louvain = community.louvain_communities(G, weight='inv_cant_materias_similares')
utils.plot_communities(G, louvain, edge_width=0.0005)
_images/grafon_7_0.png
from config import PADRON, CARRERA
from utils import plan_estudios

def nestedsearch(el, lst_of_sets):
    return list(filter(lambda lst: el in lst, lst_of_sets))[0]

def materias_padron(padron):
    return df[(df['Padron'] == padron) & (df['materia_nota'] >= 4)]['materia_id'].values

def sugerir_electivas(padron):
    min_alumnos, max_alumnos = 6, 20
    max_iteraciones = 25

    i = 0
    grupo = []
    for i in range(max_iteraciones):
        louvain = community.louvain_communities(G, weight='inv_cant_materias_similares', resolution=1+(i*0.01))
        comunidad = nestedsearch(padron, louvain)
        if min_alumnos <= len(comunidad) <= max_alumnos:
            grupo = comunidad
            break
        elif not grupo or (len(comunidad) >= max_alumnos and (len(comunidad) - max_alumnos <= len(grupo) - max_alumnos)):
            grupo = comunidad
        i+=1
    
    df_sugerencias = df_alumnos[df_alumnos['Padron'].isin(grupo)].groupby('materia_id').agg(cant_alumnos_similares=('materia_id', 'count'))
    df_sugerencias = df_sugerencias[~df_sugerencias.index.isin(materias_padron(padron))]
    
    df_materias = pd.read_json(plan_estudios(CARRERA))
    df_sugerencias = pd.merge(df_sugerencias, df_materias, left_on='materia_id', right_on="id")
    df_sugerencias = df_sugerencias[df_sugerencias['categoria'] == 'Materias Electivas']
    df_sugerencias = df_sugerencias[['id', 'materia', 'creditos', 'cant_alumnos_similares']].sort_values('cant_alumnos_similares', ascending=False)
    return df_sugerencias.reset_index(drop=True)

print(f"Top 10 electivas sugeridas a {PADRON}")
electivas = sugerir_electivas(PADRON)
electivas.head(10)
Top 10 electivas sugeridas a 100029
id materia creditos cant_alumnos_similares
0 78.xx Idioma 4 12
1 61.10 Análisis Matemático III A 6 7
2 62.15 Física III D 4 6
3 71.18 Estructura Económica Argentina 4 4
4 66.20 Organización de Computadoras 6 3
5 71.12 Estructura de las Organizaciones 6 3
6 66.06 Análisis de Circuitos 10 2
7 75.30 Teoría de Algoritmos II 6 2
8 64.05 Estática y Resistencia de Materiales B 6 1
9 66.69 Criptografía y Seguridad Informática 6 1