lunes, 7 de diciembre de 2020

Programación mínima, C# vs Python

Por puro divertimento, me ha dado por pensar cuál sería el programa mínimo que podría hacer para un compañer@ que me pide descargar una página web a un fichero.

Para esta prueba voy a comparar a C# con Python.

De Python se dice que es un lenguaje expresivo, es decir, que con una única línea de código Python se hace normalmente más que con otro lenguaje de programación (ni lo confirmo ni lo desmiento, yo creo que esta afirmación estás basada más en list comprehension o en como hacer swap de 2 variables).

Por ejemplo, si comparamos un swap de variables en Python y en C#, a priori ganaba Python, pero C# se ha puesto las pilas y empatan en número de líneas:

my_var = "my_var"
your_var = "your_var"
my_var, your_var = your_var, my_var  # this is expressive
print(my_var, your_var)  # your_var my_var
using System;

namespace ConsoleApp1
{
    class Program
    {
        static void Main(string[] args)
        {
            var myVar = "myVar";
            var yourVar = "yourVar";
            (myVar, yourVar) = (yourVar, myVar);  // This is expressive too
            Console.WriteLine($"{myVar} {yourVar}");  // yourVar myVar
        }
    }
}

Como el rival a batir es Python, veamos como haríamos la descarga de la página a un fichero:

from urllib import request
request.urlretrieve("https://www.google.es/", "google.html")

2 líneas de código es bien, menos código menos oportunidad de meter bugs.

Ahora en C#:

using System.IO;
using System.Net.Http;
using System.Threading.Tasks;

namespace ConsoleApp1
{
    class Program
    {
        static async Task Main(string[] args)
        {
            using (var httpClient = new HttpClient())
            {
                File.WriteAllText("google.html", await httpClient.GetStringAsync("https://www.google.es/"));
            }
        }
    }
}

Necesitamos hacer algo, esos malvados pythonistas nos están echando en cara que ellos en 2 líneas han hecho lo mismo que nosotros en 17. Es hora de sacar la artillería pesada, top level statements de C# 9 y .NET 5:

System.IO.File.WriteAllText("google.html", 
    await new System.Net.Http.HttpClient().GetStringAsync
    ("https://www.google.es/"));

Ahora el problema lo tiene Python, con C# lo hemos hecho en 1 línea (si no eres muy de C#, simplemente he partido la línea en varias para mejorar su legibilidad). En cualquier caso ¡menos líneas no se podían!

Habiendo escrito el mínimo código viable para llevar a cabo la tan magna tarea encomendada, todavía nos queda distribuir nuestra aplicación. Para Python vamos a usar pyinstaller y para C# directamente la CLI de dotnet.

Con Python ha sido necesario los siguientes pasos (dejando a un lado el tema de los entornos virtuales) y se ha generado un fichero .exe con un tamaño de 7,23 MB

pip install pyinstaller
pyinstaller --onefile main.py

Con C# y aprovechándonos del trimming, tenemos un fichero .exe con un tamaño de 4,33 MB (más pequeño que en Python)

dotnet publish --self-contained -c Release -r win-x64 -p:PublishTrimmed=True -p:TrimMode=Link -p:PublishSingleFile=true

Bueno, pues me quedo más tranquilo, C# está dando pasos de gigante, hemos sido capaces de escribir el mismo código de Python en menos líneas y con un menor tamaño del distribuible, queda claro que C# es un señor lenguaje y plataforma... y además también expresivo... y Python me gusta mucho, ¡ojo!, es sólo que necesitaba una cabeza de turco.

Un saludo!

domingo, 22 de noviembre de 2020

Series temporales en SQL

A propósito del post que ha escrito Jose A Bautista, Series temporales en SQL, este post es complementario (o casi idéntico y entonces se podría considerar un plagio) para terminar de entender cómo calcular las series temporales, o expresado en un lenguaje más llano, crear intervalos.

El problema al que nos enfrentamos (y resolvió, quiero insistir, Jose A Bautista de forma efectiva) era el siguiente:

Saber que productos han tenido stock pero no ventas, en un periodo determinado.

El problema del problema (un metaproblema por así decirlo) no es averiguar lo anterior, para eso no es necesario crear series temporales, sino trabajar con un gran volumen de datos y que la consulta siga ejecutándose en un tiempo razonable, por eso crear intervalos ofrece una solución porque, presumiblemente (y depende mucho de la dispersión de los datos), reducirá el conjunto de filas sobre el que se ejecuta la consulta (asumiendo también que crear esta tabla intermedia tiene un coste, tanto en tiempo como en almacenamiento).

El set de datos inicial es el siguiente:

CREATE TABLE [dbo].[Sales](
	[ProductId] [int] NOT NULL,
	[PointOfSaleId] [int] NOT NULL,
	[Date] [date] NOT NULL,
	[Quantity] [int] NOT NULL
) ON [PRIMARY]
GO
INSERT [dbo].[Sales] ([ProductId], [PointOfSaleId], [Date], [Quantity]) VALUES (1, 1, CAST(N'2020-01-01' AS Date), 1)
GO
INSERT [dbo].[Sales] ([ProductId], [PointOfSaleId], [Date], [Quantity]) VALUES (1, 1, CAST(N'2020-01-02' AS Date), 2)
GO
INSERT [dbo].[Sales] ([ProductId], [PointOfSaleId], [Date], [Quantity]) VALUES (1, 1, CAST(N'2020-01-03' AS Date), 3)
GO
INSERT [dbo].[Sales] ([ProductId], [PointOfSaleId], [Date], [Quantity]) VALUES (1, 1, CAST(N'2020-01-10' AS Date), 10)
GO
INSERT [dbo].[Sales] ([ProductId], [PointOfSaleId], [Date], [Quantity]) VALUES (1, 1, CAST(N'2020-01-11' AS Date), 11)
GO
INSERT [dbo].[Sales] ([ProductId], [PointOfSaleId], [Date], [Quantity]) VALUES (1, 1, CAST(N'2020-01-20' AS Date), 20)
GO
INSERT [dbo].[Sales] ([ProductId], [PointOfSaleId], [Date], [Quantity]) VALUES (2, 1, CAST(N'2020-01-01' AS Date), 1)
GO
INSERT [dbo].[Sales] ([ProductId], [PointOfSaleId], [Date], [Quantity]) VALUES (2, 1, CAST(N'2020-01-02' AS Date), 2)
GO
INSERT [dbo].[Sales] ([ProductId], [PointOfSaleId], [Date], [Quantity]) VALUES (2, 1, CAST(N'2020-01-05' AS Date), 5)
GO
INSERT [dbo].[Sales] ([ProductId], [PointOfSaleId], [Date], [Quantity]) VALUES (2, 2, CAST(N'2020-01-15' AS Date), 15)
GO
INSERT [dbo].[Sales] ([ProductId], [PointOfSaleId], [Date], [Quantity]) VALUES (2, 2, CAST(N'2020-01-16' AS Date), 16)
GO
INSERT [dbo].[Sales] ([ProductId], [PointOfSaleId], [Date], [Quantity]) VALUES (2, 2, CAST(N'2020-01-25' AS Date), 25)

Finalmente tenemos los siguientes datos:

ProductId PointOfSaleId Date Quantity
1 1 01-01-2020 1
1 1 01-02-2020 2
1 1 01-03-2020 3
1 1 01-10-2020 10
1 1 01-11-2020 11
1 1 01-20-2020 20
2 1 01-01-2020 1
2 1 01-02-2020 2
2 1 01-05-2020 5
2 2 01-15-2020 15
2 2 01-16-2020 16
2 2 01-25-2020 25

El siguiente paso es entender la función ROW_NUMBER

-- Sin PARTITION, simplemente tenemos un contador
SELECT ROW_NUMBER() OVER (ORDER BY ProductId) AS rn, * FROM Sales
rn ProductId PointOfSaleId Date Quantity
1 1 1 01-01-2020 1
2 1 1 01-02-2020 2
3 1 1 01-03-2020 3
4 1 1 01-10-2020 10
5 1 1 01-11-2020 11
6 1 1 01-20-2020 20
7 2 1 01-01-2020 1
8 2 1 01-02-2020 2
9 2 1 01-05-2020 5
10 2 2 01-15-2020 15
11 2 2 01-16-2020 16
12 2 2 01-25-2020 25
-- PARTITION BY cambia, así que se reinicia el contador
    SELECT ROW_NUMBER() OVER (PARTITION BY ProductId, PointOfSaleId ORDER BY ProductId) AS rn, * FROM Sales    
rn ProductId PointOfSaleId Date Quantity
1 1 1 01-01-2020 1
2 1 1 01-02-2020 2
3 1 1 01-03-2020 3
4 1 1 01-10-2020 10
5 1 1 01-11-2020 11
6 1 1 01-20-2020 20
1 2 1 01-01-2020 1
2 2 1 01-02-2020 2
3 2 1 01-05-2020 5
1 2 2 01-15-2020 15
2 2 2 01-16-2020 16
3 2 2 01-25-2020 25

Ahora que ya sabemos como funciona ROW_NUMBER, vamos a centranos en lo que queremos conseguir, el resultado final, los intervalos:

ProductId PointOfSaleId StartDate EndDate Quantity
1 1 01-01-2020 01-03-2020 6
1 1 01-10-2020 01-11-2020 21
1 1 01-20-2020 01-20-2020 20
2 1 01-01-2020 01-02-2020 3
2 1 01-05-2020 01-05-2020 5
2 2 01-15-2020 01-16-2020 31
2 2 01-25-2020 01-25-2020 25

Para llegar del conjunto inicial de datos al resultado final, la SQL necesaria es:

WITH sales_cte AS
    (SELECT ProductId,
            PointOfSaleId, 
            Date,
            SUM(Quantity) AS Quantity
     FROM Sales
     GROUP BY ProductId,
              PointOfSaleId,
              Date),
       groups_cte AS
    (SELECT ROW_NUMBER() OVER (ORDER BY date) AS row_number,
      DATEADD(DAY, -ROW_NUMBER() OVER (PARTITION BY ProductId, PointOfSaleId ORDER BY Date), Date) AS [group],
          ProductId,
          PointOfSaleId , 
          Date,
          Quantity
     FROM sales_cte)   
  SELECT ProductId,
         PointOfSaleId,
         MIN(Date) AS StartDate,
         MAX(Date) AS EndDate,
         SUM(Quantity) AS Quantity
  FROM groups_cte
  GROUP BY ProductId,
           PointOfSaleId,
           [group]  

La clave de la SQL está en -ROW_NUMBER(), vamos a ver el resultado intermedio y entender así que los días consecutivos están creando un grupo que podremos usar posteriormente:

SELECT 
	ROW_NUMBER() OVER (ORDER BY date) AS row_number,
	ProductId,
    PointOfSaleId,
	Date,
	Quantity,
	-ROW_NUMBER() OVER (PARTITION BY ProductId, PointOfSaleId ORDER BY Date) AS negative_row_number,
    DATEADD(DAY, -ROW_NUMBER() OVER (PARTITION BY ProductId, PointOfSaleId ORDER BY Date), Date) AS [group]
INTO #groups
FROM (SELECT ProductId,
          PointOfSaleId, 
		  Date,
		  SUM(Quantity) AS Quantity
   FROM Sales   
   GROUP BY ProductId,
            PointOfSaleId,
            Sales.Date) as T;
SELECT * FROM #groups;
row_number ProductId PointOfSaleId Date Quantity negative_row_number group
1 1 1 01-01-2020 1 -1 12-31-2019
3 1 1 01-02-2020 2 -2 12-31-2019
5 1 1 01-03-2020 3 -3 12-31-2019
7 1 1 01-10-2020 10 -4 01-06-2020
8 1 1 01-11-2020 11 -5 01-06-2020
11 1 1 01-20-2020 20 -6 01-14-2020
2 2 1 01-01-2020 1 -1 12-31-2019
4 2 1 01-02-2020 2 -2 12-31-2019
6 2 1 01-05-2020 5 -3 01-02-2020
9 2 2 01-15-2020 15 -1 01-14-2020
10 2 2 01-16-2020 16 -2 01-14-2020
12 2 2 01-25-2020 25 -3 01-22-2020

Después de esto ya podemos agrupar por el grupo creado (la columna [group]) y usar funciones de agregado para sacar el resto de los datos:

SELECT ProductId,
       PointOfSaleId,
	   [group],
       MIN(Date) AS StartDate,
       MAX(Date) AS EndDate,
	   SUM(Quantity) AS Quantity
FROM #groups
GROUP BY ProductId,
         PointOfSaleId,
         [group]
ProductId PointOfSaleId group StartDate EndDate Quantity
1 1 12-31-2019 01-01-2020 01-03-2020 6
1 1 01-06-2020 01-10-2020 01-11-2020 21
1 1 01-14-2020 01-20-2020 01-20-2020 20
2 1 12-31-2019 01-01-2020 01-02-2020 3
2 1 01-02-2020 01-05-2020 01-05-2020 5
2 2 01-14-2020 01-15-2020 01-16-2020 31
2 2 01-22-2020 01-25-2020 01-25-2020 25

Por acabar con algún dato del mundo real, para el cliente en el que estamos haciendo algunas pruebas la reducción de filas ha sido la siguiente:

Original rows Intervals rows
Stock 560.089.291 12.236.634
Sales 22.981.825 16.794.972

Un saludo!