Niveau
Débutant
Env.
Local (Windows), Local (Linux), Google Colab, Kaggle
Code
Python
Libs
pandas
Sources

Profiler vos données tout simplement avec Python et Pandas

Il existe bien sur beaucoup de librairies (comme Pandas Profiling ou plutôt ydata-profiling dorénavant) et surtout beaucoup d’outils qui permettent plus ou moins simplement d’effectuer une analyse structurelle de vos données (on se limitera ici d’un profiling de table).

Mais voilà, parfois une approche minimaliste suffit amplement. Rien ne nécessite la mise en place d’une artillerie lourde et couteuse en ressource ! L’idée de cet article est de vous montrer qu’avec la simple (mais très riche) librairie Pandas vous avez déjà de quoi vous satisfaire amplement.

Ne l’oubliez pas la loi de Pareto stipule que 80% de la valeur est dégagée par seulement 20% des efforts … alors voulez vous vraiment vous fatiguer pour peu ou pas grand chose ?

Préparation

Les ingrédients pour la recette sont très simple puisque comme mentionné plus haut nous nous contenterons de n’utiliser que la librairie Python Pandas !

L’objectif quant à lui est plutôt simple, analyser les données d’un dataset et y renvoyer ces informations dans un format dict/JSON. Pourquoi JSON, tout simplement parce que l’analyse va renvoyer des données qui peuvent être assimilées à un arbre (parfait donc pour JSON). On trouvera tout d’abord les informations générales du jeu de données comme le nombre de lignes, de colonnes, etc. puis pour chaque colonne nous renverrons une analyse plus détaillée bien sur:

  • Nom
  • Type
  • Pattern (format)
  • Valeurs les plus fréquentes
  • Type réel
  • Statistiques
  • etc.

Voici un exemple de ce que l’on désirerait connaitre à la suite de cette analyse:

JSON
{
  "rows count": 14,
  "columns count": 16,
  "columns names": [
    "id","concept:name", ...
  ],
  "columns": [
    {
      "name": "id",
      "type": "float64",
      "inferred": "float64",
      "distinct": 12,
      "nan": 2,
      "null": 2,
      "stats": {
        "count": 12,"mean": 6.166666666666667, ...
      },
      "top values": {
        "0.0": 1, "1.0": 1, ...
      },
      "pattern": {
        "N.N": 9, "NN.N": 3,...
      },
      "types": {
        "number": 12, "null": 2, ...
      }
    },
...
  ]
}

Une fois de plus avec Pandas nous avons tout ce qu’il faut pour fournir ce type de résultat et ce en quelques lignes de code …

Première analyse

Commençons par importer les librairies nécessaires et créer un jeu de données d’essai:

Python
import re
import pandas as pd
import math
import datetime
from collections import Counter
data = {
    'A': ['abc123', 'def/456', 'ghi-789', 'jkl101', 'AAA', '111', None, 'vwx145', 'yz156', 'lmn167'],
    'B': [1, 1, 3, 4, 5, 5, 7, 8, 9, 9],
    'C': ['foo', 1, 'foo', 'bar', 15, 'bar', 'foo', "2023/01/10", 'foo', 'bar']
}
df = pd.DataFrame(data)

Ok, j’ai dit qu’on utiliserait que Pandas, mais en réalité nous allons aussi utiliser des librairies de base Python comme re pour les Regex, math, datetime, etc. 😉

Le jeu de données est lui volontairement très simple, j’y ai néanmoins ajouté quelques soucis comme des valeurs manquantes, des types multiples sur la même colonne, etc.

Détection de format

Voilà une fonctionnalité particulièrement pratique quand on ne connait pas son jeu de données et que l’on veut voir si les colonnes ont des formats particuliers. La détection de format ou de pattern peut s’effectuer en quelques lignes seulement, et grâce aux regex on peut faire à peut près tout ce que l’on veut. Ci dessous une simple fonction qui permet de détecter un pattern numérique ou de type chaine. L’idée est plutot simple, à la lecture de la donnée, la fonction va remplacer chaque chiffre par N et chaque caractère (hors chiffre) par C. J’ai aussi rajouté la notion de bruit pour tous les autres caractères qui ne m’interresserait pas.

Python
REGEX_CARS = r'[a-zA-Z]'
REGEX_NUMS = r'\d'
REGEX_NOISE = r'[µ&#@^~]'
REGEX_SPECCARS = r'[<>µ+=*&\'"#{}()|/\\@][]^~]'
REGEX_PONCT = r'[,.;:?!]'
def getStringPattern(string):
    if (string == 'nan' or string == None):
        return "NULL"
    mystr = re.sub(REGEX_CARS, 'C', string)
    mystr = re.sub(REGEX_NUMS, 'N', mystr)
    mystr = re.sub(REGEX_NOISE, '?', mystr)
    return mystr
df2 = pd.DataFrame()
df2['profile'] = df['A'].apply(getStringPattern)
print(df2)
profile
0 	CCCNNN
1 	CCC/NNN
2 	CCC-NNN
3 	CCCNNN
4 	CCC
5 	NNN
6 	NULL
7 	CCCNNN
8 	CCNNN
9 	CCCNNN

Pratique n’est ce pas ? notamment pour trouver une codification dans un jeu de données complexe, etc.

Maintenant pour placer le résultat dans un JSON (ou dictionnaire Python), rien de plus simple, on utilise la fonction to_dict() de pandas :

Python
print(df2['profile'].value_counts().to_dict())
{'CCCNNN': 4,
'CCC/NNN': 1,
'CCC-NNN': 1,
'CCC': 1,
'NNN': 1,
'NULL': 1,
'CCNNN': 1}

Découvrir le type réel

Maintenant attaquons nous à une autre analyse extrêmement pratique : la détection du type réel de la donnée stockée. Nous savons en effet que les chaines de caractères servent de fourre-tout dans lesquelles on peut stocker à peut près n’importe quoi: Nombre, dates, etc. il serait intéressant donc d’analyser le contenu réel de chaque colonne et de renvoyer quelques statistiques concrètes. C’est l’objectife de la fonction suivante qui va renvoyer le type réel de la donnée:

Python
def getType(value):
	"""Returns the data type on the value in input
	Args:
		value (object): data
	Returns:
		str: type namen can be [ null, number, date, string, unknown]
	"""
	if value is None:
		return "null"
	if isinstance(value, (int, float)):
		if math.isnan(value):
			return "null"
		else:
			return "number"
	elif isinstance(value, str):
		return "string"
	elif isinstance(value, datetime.datetime):
		return "date"
	else:
		return "unknown"

Essayons la sur notre jeu de données:

Python
df2 = pd.DataFrame()
df2['types'] = df['C'].apply(getType)
print(df2)
 	types
0 	string
1 	number
2 	string
3 	string
4 	number
5 	string
6 	string
7 	string
8 	string
9 	string

Hum, c’est pas mal mais il semblerait que ma date « 2023/01/10 » ait été détectée comme une chaine de caractère (ce qui est normal) … je voudrais aussi détecter des dates dans mon jeu de données ! pour cela nous allons utiliser un module supplémentaire (dateutil) qui va faire ce travail pour nous et voici la nouvelle fonction:

Python
def getType(value):
	"""Returns the data type on the value in input
	Args:
		value (object): data
	Returns:
		str: type namen can be [ null, number, date, string, unknown]
	"""
	if value is None:
		return "null"
	if isinstance(value, (int, float)):
		if math.isnan(value):
			return "null"
		else:
			return "number"
	elif isinstance(value, str):
		try:
			parse(value, fuzzy=False)
			return "date"
		except ValueError:
			return "string"
	elif isinstance(value, datetime.datetime):
		return "date"
	else:
		return "unknown"

Relançons la détection …

Python
df2 = pd.DataFrame()
df2['types'] = df['C'].apply(getType)
print(df2)
 	types
0 	string
1 	number
2 	string
3 	string
4 	number
5 	string
6 	string
7 	date
8 	string
9 	string

C’est beaucoup mieux 🙂

Fonction globale

Voilà nous avons maintenant (outre les fonctions intrinsèques de Pandas) pour fournir notre profiling de données, voici donc la fonction que l’on pourrait utiliser:

Python
def profile(df, maxvaluecounts=10) -> dict:
	"""Build a JSON which contains some basic profiling informations
	Args:
		maxvaluecounts (int, optional): Limits the number of value_counts() return. Defaults to 10.
	Returns:
		json: data profile in a JSON format
	"""
	profile = {}
	# Get stats per columns/fields
	profileColumns = []
	for col in df.columns:
		profileCol = {}
		counts = df[col].value_counts()
		profileCol['name'] = col
		profileCol['type'] = str(df[col].dtypes)
		profileCol['inferred'] = str(df[col].infer_objects().dtypes)
		profileCol['distinct'] = int(len(counts))
		profileCol['nan'] = int(df[col].isna().sum())
		profileCol['null'] = int(df[col].isnull().sum())
		profileCol['stats'] = df[col].describe().to_dict()
		profileCol['top values'] = dict(Counter(counts.to_dict()).most_common(maxvaluecounts))
		# Get pattern for that column
		dfProfPattern = pd.DataFrame()
		dfProfPattern['profile'] = df[col].apply(lambda x:getStringPattern(str(x)))
		profileCol['pattern'] = dict(Counter(dfProfPattern['profile'].value_counts().to_dict()).most_common(maxvaluecounts))
		# get types for that columns
		dfProfType = pd.DataFrame()
		dfProfType['types'] = df[col].apply(lambda x:getType(x))
		profileCol['types'] = dict(Counter(dfProfType['types'].value_counts().to_dict()).most_common(maxvaluecounts))
		profileColumns.append(profileCol)
	profile["rows count"] = df.shape[0]
	profile["columns count"] = len(df.columns)
	profile["columns names"] = [ name for name in df.columns ]
	profile["columns"] = profileColumns
	return profile

Maintenant essayez là en tapant juste:

Python
profile(df)

Vous remarquerez le paramètre maxvaluecounts qui permet de limiter le nombre de résultat sur certaines sous fonctions comme les valeurs les plus fréquentes ou même les types les plus utilisés.

Conclusion

L’objectif de ce post était juste de montrer que bien souvent il est très simple d’utiliser des fonctions déjà à disposition plutôt que d’importer des librairies ou pire d’utiliser des outils plus complexe. Cela ne signifie pas bien sur qu’il faut toujours avoir cette approche « minimaliste » mais à contrario adapter au besoins réel tout en limitant le code et les ressources permet aussi de limiter les erreurs et la maintenance. En bref avant d’installer un outil de Profiling, il est intéressant de bien s’interroger sur l’objectif réel de cette analyse et des bénéfices que vous en attendez.

Note: Cette fonction est dorénavant native dans le projet pipelite.

Partager cet article

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.