Este posteo explica el primer demo de la presentación de análisis de abandono de clientes. La intención de que puedas replicarla o adaptarla a tu ambiente. El objetivo final de la presentación es buscar el análisis de la pérdida de clientes antes de crear los modelos predictivos. El abandono o pérdida de clientes lo llamamos también en inglés “Customer Churn”.
Para crear el modelo de abandono de clientes, inicie con un modelo lo más modesto posible y que permita enforcarse en el problema que queremos resolver y no distraiga demasiado. Hay algunos detalles sin embargo que no deben pasar desapercibidos:
- Las tablas de transacciones (en este caso las ventas) son tablas delgadas y altas. O sea, de pocas columnas y muchas filas.
- Las tablas de catalogo son tablas gruesas y bajas. O sea, de muchas columnas y generalmente pocas filas (aunque no siempre).
Este patrón (basado en el modelo dimensional de Kimball) hace modelos de Inteligencia de negocios que tienen mejor desempeño y más “inteligentes”. Modelos que tienen 3 o 4 atributos en las dimensiones o catálogos son modelos de bajo IQ (Coeficiente Intelectual) y por ende no son muy útiles.
Diagrama del Modelo de Datos
Este diagrama es OPTATIVO, se usa solo si quieres crear una representación gráfica de las entidades. Para diagramar use https://dbdiagram.io/. Se usa el siguiente pseudo código, que crea tablas, pero en realidad son vistas.
//// -- LEVEL 1
//// -- Tables and References
// Creating tables
Table Sales {
SalesOrderID int [pk, increment]
OrderDate date
DueDate date
ShipDate date
CustomerID int
SalesPersonID int
TerritoryID int
BillToAddressID int
ShipToAddressID int
ShipMethodID int
CreditCardID int
SubTotal money
TaxAmt money
Freight money
TotalDue money
}
Table Stores {
CustomerID int [pk, increment]
Name VARCHAR(30)
AddressType VARCHAR(30)
AddressLine1 VARCHAR(60)
AddressLine2 VARCHAR(60)
City VARCHAR(30)
StateProvinceName VARCHAR(30)
PostalCode VARCHAR(15)
CountryRegionName VARCHAR
}
Table Individuals{
CustomerID int [pk, increment]
FullName nvarchar(152)
AddressType varchar(30)
AddressLine1 nvarchar(60)
AddressLine2 nvarchar(60)
City nvarchar(30)
StateProvinceName varchar(30)
PostalCode nvarchar(15)
CountryRegionName varchar(30)
EmailPromotion int
Occupation varchar(50)
Education varchar(50)
HomeOwnerFlag bit
NumberCarsOwned int
Gender char(1)
MaritalStatus char(1)
BirthDate date
DateFirstPurchase date
}
TABLE Dates{
DateId int
Date date [pk]
DateFullName varchar(15)
DayNumberOfMonth int
DayNumberOfQuarter int
DayNumberOfYear int
MonthKey smallint
MonthName varchar(10)
MonthFullKey int
MonthFullName varchar(15)
MonthNumberOfQuarter int
MonthNumberOfYear int
MonthDaysInMonth int
MonthBeginDate varchar(15)
MonthEndDate varchar(15)
QuarterKey smallint
QuarterName char(2)
QuarterFullKey int
QuarterFullName char(7)
QuarterNumberOfDays int
QuarterBeginDate varchar(15)
QuarterEndDate varchar(15)
YearKey smallint
YearName varchar(4)
YearNumberOfDays int
YearBeginDate varchar(15)
YearEndDate varchar(15)
DayKey smallint
DayName varchar(10)
WeekKey smallint
WeekName char(3)
WeekFullKey int
WeekFullName char(8)
WeekBeginDate varchar(15)
WeekEndDate varchar(15)
FiscalMonthFullName varchar(20)
FiscalQuarterFullName varchar(12)
FiscalYearKey smallint
FiscalYearName varchar(9)
FiscalYearBeginDate varchar(15)
FiscalYearEndDate varchar(15)
DateTypeKey smallint
DateTypeName varchar(12)
HolidayKey int
HolidayName varchar(40)
WeatherSeasonKey smallint
WeatherSeasonName varchar(20)
WeatherSeasonFullKey int
WeatherSeasonFullName varchar(20)
WeatherYearKey smallint
WeatherYearName varchar(4)
BusinessSeasonKey smallint
BusinessSeasonName varchar(20)
BusinessSeasonFullKey int
BusinessSeasonFullName varchar(26)
BusinessYearKey smallint
BusinessYearName varchar(4)
}
Ref: Sales.CustomerID > Stores.CustomerID
Ref: Sales.CustomerID > Individuals.CustomerID
Ref: Sales.OrderDate > Dates.Date
Hay que notar la relación de Individuos y Tiendas. En realidad, hay una entidad “escondida” de clientes que no está modelada y que existe entre órdenes y las entidades de Tiendas e individuos. Reitero, que esta entidad NO se modela para reflejar la realidad de lo que nos encontramos en las bases de datos usuales, y es que no siempre los modelos son perfectamente bien modelados. La grafica resultante del modelo es la siguiente:

Las entidades usadas son:
Entidad de Ordenes (Facturas o Albaranes)
En esta consulta solo ajusto las fechas para que queden más cerca del día de hoy. Muchos atributos se pueden quitar, los dejo porque pueden ser interesantes para otras demos.
SELECT SalesOrderID,
CAST(DATEADD(MONTH, 6, DATEADD(YEAR, 5, OrderDate)) AS DATE) AS OrderDate,
CAST(DATEADD(MONTH, 6, DATEADD(YEAR, 5, DueDate)) AS DATE) AS DueDate,
CAST(DATEADD(MONTH, 6, DATEADD(YEAR, 5, ShipDate)) AS DATE) AS ShipDate,
---, OnlineOrderFlag, SalesOrderNumber
--, PurchaseOrderNumber, AccountNumber
CustomerID,
SalesPersonID,
TerritoryID,
--BillToAddressID,
--ShipToAddressID,
--ShipMethodID,
--CreditCardID,
--, CreditCardApprovalCode
--, CurrencyRateID
SubTotal,
TaxAmt,
Freight,
TotalDue
FROM Sales.SalesOrderHeader AS OH;
Resultado de la consulta:
SalesOrderID | OrderDate | DueDate | ShipDate | CustomerID | SalesPersonID | TerritoryID | SubTotal | TaxAmt | Freight | TotalDue |
43659 | 2016-11-30 | 2016-12-12 | 2016-12-07 | 29825 | 279 | 5 | 20565.6206 | 1971.5149 | 616.0984 | 23153.2339 |
43660 | 2016-11-30 | 2016-12-12 | 2016-12-07 | 29672 | 279 | 5 | 1294.2529 | 124.2483 | 38.8276 | 1457.3288 |
43661 | 2016-11-30 | 2016-12-12 | 2016-12-07 | 29734 | 282 | 6 | 32726.4786 | 3153.7696 | 985.5530 | 36865.8012 |
43662 | 2016-11-30 | 2016-12-12 | 2016-12-07 | 29994 | 282 | 6 | 28832.5289 | 2775.1646 | 867.2389 | 32474.9324 |
43663 | 2016-11-30 | 2016-12-12 | 2016-12-07 | 29565 | 276 | 4 | 419.4589 | 40.2681 | 12.5838 | 472.3108 |
43664 | 2016-11-30 | 2016-12-12 | 2016-12-07 | 29898 | 280 | 1 | 24432.6088 | 2344.9921 | 732.8100 | 27510.4109 |
43665 | 2016-11-30 | 2016-12-12 | 2016-12-07 | 29580 | 283 | 1 | 14352.7713 | 1375.9427 | 429.9821 | 16158.6961 |
43666 | 2016-11-30 | 2016-12-12 | 2016-12-07 | 30052 | 276 | 4 | 5056.4896 | 486.3747 | 151.9921 | 5694.8564 |
43667 | 2016-11-30 | 2016-12-12 | 2016-12-07 | 29974 | 277 | 3 | 6107.0820 | 586.1203 | 183.1626 | 6876.3649 |
43668 | 2016-11-30 | 2016-12-12 | 2016-12-07 | 29614 | 282 | 6 | 35944.1562 | 3461.7654 | 1081.8017 | 40487.7233 |
Vista de Tiendas
Tiene muy pocos atributos interesantes, solo los relacionados con geografía. En un ambiente de producción, deberíamos considerar buscar otros atributos relevantes. En esta consulta, el truco más relevante, es usar la función ROW_NUMBER para escoger la dirección preferida, en este caso ordenada de acuerdo con una regla de negocio donde Main Office es preferida, luego Primary y de último Billing. Otra nota importante es asegurarse que la consulta sea determinística, o sea que siempre devuelve el mismo conjunto de datos por eso se usa en el ORDER BY del ROW_NUMBER la columna AddressTypeID para asegurarse que siempre asigna el mismo número a las filas en caso de empate, dicho de otra forma, AddressTypeID sirve para desempatar. Adicionalmente se filtran solo los clientes con ordenes por eso la condición en el WHERE filtra clientes registrados sin órdenes. Finalmente, revise que en parte inferior de la consulta se usa el ROW_NUMBER para que escoger solo una dirección por cliente.
CREATE VIEW Demos.Stores AS
WITH BaseStore AS (
SELECT
Cus.CustomerID
,s.Name
,at.Name AS AddressType
,a.AddressLine1
,a.AddressLine2
,a.City
,sp.Name AS StateProvinceName
,a.PostalCode
,cr.Name AS CountryRegionName
, ROW_NUMBER() OVER(PARTITION BY Cus.CustomerID
ORDER BY CASE at.Name WHEN 'Main Office' THEN 1
WHEN 'Primary' THEN 2
WHEN 'Billing' THEN 3
ELSE 4 END, bea.AddressTypeID ) AS RSel
FROM Sales.Customer AS Cus
JOIN Sales.Store AS S
ON Cus.StoreID=S.BusinessEntityID
--- Dups
INNER JOIN Person.BusinessEntityAddress AS bea
ON bea.BusinessEntityID = s.BusinessEntityID
INNER JOIN Person.Address AS a
ON a.AddressID = bea.AddressID
INNER JOIN Person.StateProvince AS sp
ON sp.StateProvinceID = a.StateProvinceID
INNER JOIN Person.CountryRegion AS cr
ON cr.CountryRegionCode = sp.CountryRegionCode
INNER JOIN Person.AddressType AS at
ON at.AddressTypeID = bea.AddressTypeID
WHERE Cus.StoreID IS NOT NULL
AND Cus.CustomerID IN (SELECT oh.CustomerID
FROM Sales.SalesOrderHeader AS OH)
)
SELECT CustomerID, Name, AddressType, AddressLine1, AddressLine2, City, StateProvinceName, PostalCode
, CountryRegionName
FROM BaseStore
WHERE RSel=1;
SELECT TOP 20 *
FROM Demos.Stores;
CustomerID | Name | AddressType | AddressLine1 | AddressLine2 | City | StateProvinceName | PostalCode | CountryRegionName |
29484 | Next-Door Bike Store | Main Office | Mall Of Memphis | NA | Memphis | Tennessee | 38103 | United States |
29485 | Professional Sales and Service | Main Office | 57251 Serene Blvd | NA | Van Nuys | California | 91411 | United States |
29486 | Riders Company | Main Office | Tanger Factory | NA | Branch | Minnesota | 55056 | United States |
29487 | The Bike Mechanics | Main Office | Johnny Appleseed Shop.center | NA | Mansfield | Ohio | 44903 | United States |
29488 | Nationwide Supply | Main Office | 4250 Concord Road | NA | Rhodes | New South Wales | 2138 | Australia |
29489 | Area Bike Accessories | Main Office | 6900 Sisk Road | NA | Modesto | California | 95354 | United States |
29490 | Bicycle Accessories and Kits | Main Office | Lewiston Mall | NA | Lewiston | Idaho | 83501 | United States |
29491 | Clamps & Brackets Co. | Main Office | Leesburg Premium Outlet Centre | NA | Leesburg | Virginia | 20176 | United States |
29492 | Valley Bicycle Specialists | Main Office | Blue Ridge Mall | NA | Kansas City | Missouri | 64106 | United States |
29493 | New Bikes Company | Main Office | Hilton Head Factory Outlets No. 25 | NA | Bluffton | South Carolina | 29910 | United States |
Vista de Individuos
La más interesante desde el punto de vista de atributos para análisis. Con la misma técnica de filtros de direcciones. El uso del CROSS APPLY para extraer de la columna XML los datos demográficos y normalizarlos.
CREATE VIEW Demos.Individuals
AS
WITH XMLNAMESPACES ('http://schemas.microsoft.com/sqlserver/2004/07/adventure-works/IndividualSurvey' AS sv)
, BaseIndividuos AS (
SELECT
C.CustomerID,
RTRIM(
CONCAT(
COALESCE(p.FirstName + ' ', '')
, COALESCE(p.MiddleName + ' ', '')
, COALESCE(p.LastName, '')
)) AS FullName
,at.Name AS AddressType
,a.AddressLine1
,a.AddressLine2
,a.City
,StateProvinceName = sp.Name
,a.PostalCode
,CountryRegionName = cr.Name
,p.EmailPromotion
, C.value('sv:Occupation[1]','varchar(50)') AS Occupation
, C.value('sv:Education[1]','varchar(50)') AS Education
, C.value('sv:HomeOwnerFlag[1]','bit') AS HomeOwnerFlag
, C.value('sv:NumberCarsOwned[1]','int') AS NumberCarsOwned
, C.value('sv:Gender[1]','char(1)') AS Gender
, C.value('sv:MaritalStatus[1]','char(1)') AS MaritalStatus
, C.value('sv:BirthDate[1]','date') AS BirthDate
, CAST(DATEADD(MONTH, 6, DATEADD(YEAR, 5,
C.value('sv:DateFirstPurchase[1]','date'))) AS DATE) AS DateFirstPurchase
, ROW_NUMBER() OVER(PARTITION BY C.CustomerID
ORDER BY CASE at.Name WHEN 'Home' THEN 1
ELSE 4 END, bea.AddressTypeID ) AS RSel
FROM Sales.Customer AS c
JOIN Person.Person p
ON c.PersonID = p.BusinessEntityID
INNER JOIN Person.BusinessEntityAddress bea
ON bea.BusinessEntityID = p.BusinessEntityID
INNER JOIN Person.Address a
ON a.AddressID = bea.AddressID
INNER JOIN Person.StateProvince sp
ON sp.StateProvinceID = a.StateProvinceID
INNER JOIN Person.CountryRegion cr
ON cr.CountryRegionCode = sp.CountryRegionCode
INNER JOIN Person.AddressType at
ON at.AddressTypeID = bea.AddressTypeID
LEFT OUTER JOIN Person.EmailAddress ea
ON ea.BusinessEntityID = p.BusinessEntityID
LEFT OUTER JOIN Person.PersonPhone pp
ON pp.BusinessEntityID = p.BusinessEntityID
LEFT OUTER JOIN Person.PhoneNumberType pnt
ON pnt.PhoneNumberTypeID = pp.PhoneNumberTypeID
CROSS APPLY p.Demographics.nodes('/sv:IndividualSurvey') AS SV(C)
WHERE c.PersonID IS NOT NULL
AND c.CustomerID IN (SELECT oh.CustomerID
FROM Sales.SalesOrderHeader AS OH)
)
SELECT CustomerID, FullName, AddressType, AddressLine1, AddressLine2, City, StateProvinceName, PostalCode
, CountryRegionName, EmailPromotion, Occupation, Education, HomeOwnerFlag, NumberCarsOwned, Gender
, MaritalStatus, BirthDate, DateFirstPurchase
FROM BaseIndividuos
WHERE RSel=1;
Tabla de Fechas
La tabla de fechas, pertenece a una demostración más compleja y tiene su propia presentación (Construyendo una Dimensión de Tiempo excepcional) y los detalles técnicos los dejo para ese momento.
Próximamente estaré agregando el resto de las demostraciones. Si te queda alguna duda técnica o si solo quieres comentarme como te fue con la replicación del ejemplo, déjamelo saber en los comentarios.
Deja una respuesta