至此,您已经有了扎实的DAX理论知识,现在是时候开始使用DAX解决一些有趣的场景了。在本章中,我们将展示DAX使用的一些示例。
请注意,本章的目标不是提供随时可用的模式。对于每个场景,我们将构建公式并描述计算,目的是展示使用DAX解决问题的过程。
本章的目标是帮助您开始“在DAX中思考。”根据我们的经验,一旦您获得这种独特的技能,DAX就变得简单易用。在DAX中描述算法的方式与SQL、Microsoft Excel、MDX或任何其他编程语言有很大不同。一开始,学生的感觉总是:“是的,我理解这个公式,但我从来没有能够单独写出来。”过了一段时间,感觉消失了,你就可以写出比我们这里展示的更复杂的公式了。
6.1 计算比率和百分比
第一个例子非常简单,我们在前几章中已经介绍过一部分了。计算百分比可能是您需要编写的第一种计算方法,因为它是表示趋势和共享的一种非常常见的方法。
百分比始终采用除法的形式:度量值的部分和除以相同度量值的总和。例如,销售额占总销售额的百分比。包含百分比的典型报告如图6-1所示。
在图6-1中,显示的百分比是相对于总计的百分比。事实上,唯一可以看到百分之百的单元格是总计。所有剩余的单元格显示较小的百分比,即该年份和颜色对总计的贡献。
表示这种度量值的标准方法是使用简单的除法:
[Sales %] :=
DIVIDE (
[Sales Amount],
CALCULATE ( [Sales Amount], ALLSELECTED () )
)
在分母处,CALCULATE使用ALLSELECTED函数创建一个筛选上下文,其中原始上下文可见。使用ALLSELECTED以遵守使用切片器和筛选器在数据透视表中设置的筛选器。
有时您不希望仅计算销售额占总计的百分比。例如,为了生成如图6-2所示的报告,您可能想仅显示每年该颜色的销售额占当年总销售额的百分比。
为了计算年份的总和,在分母处,您需要限制其筛选上下文,以便它包含所选产品,并且对于年份,仅包含当前可见的产品。您可以通过添加VALUES函数轻松完成此操作,该函数返回当前在筛选上下文中可见的列的值:
[Yearly %] :=
DIVIDE (
[Sales Amount],
CALCULATE (
[Sales Amount],
ALLSELECTED (),
VALUES ( Date[CalendarYear] )
))
值得记住的是,CALCULATE中的筛选器在应用于模型之前会被放入AND中。因此,第一个ALLSELECTED将显示原始筛选上下文,而VALUES将返回当前可见年份,并且在每个单元格中,它将仅包含该年份。唯一的例外是列的总计。在每一行中,VALUES返回两个可见年份。
如您所见,到目前为止,我们使用了一种模式来计算分母:
- 还原原始筛选上下文
- 重新应用筛选器来限制原始上下文(例如,到当前年份)。
您也可以使用相反的模式,得到类似的结果,也就是说,您可以从当前筛选上下文开始,从要总计的列中删除筛选器,而不是恢复原始筛选器,然后重新应用筛选器。例如,要重现图6-1中的报告,您可以在“颜色”和“日历年”中恢复筛选器,而不会更改其他筛选器,如:
[Yearly %] :=
DIVIDE (
[Sales Amount],
CALCULATE (
[Sales Amount],
ALLSELECTED ( Product[Color] ),
ALLSELECTED ( Date[CalendarYear] )
))
您还可以使用这段代码获得图6-2的度量值,它只从颜色中删除筛选器,保持年份上筛选器不变:
[Yearly %-2] :=
DIVIDE (
[Sales Amount],
CALCULATE (
[Sales Amount],
ALLSELECTED ( Product[Color] )
))
虽然这些度量看起来相似,但这意味着在作为示例显示的数据透视表中它们返回相同的值,但是它们之间存在很大差异。实际上,后一版本的Yearly%会恢复颜色上的原始筛选器,但保留其他任何筛选器。如果year是筛选的唯一列,结果是相同的,但是,只要在日历表上添加更多筛选器,两个度量值将返回不同的值,如图6-3所示。
如您所见,Yearly%返回销售额与年份的百分比,而Yearly%-2返回月份的百分比,因为我们将月份添加到数据透视表。并不是一个数字是正确的,另一个是错误的。像往常一样,它取决于您想要计算的数量。
这里要记住的重要一点是,无论何时计算百分比,您都需要非常清楚分母需要是什么,以及当用户向数据透视表或报表添加更多筛选器时应该发生什么。
6.2 计算累计总计
您可能会发现常用的另一种模式是累积总计模式。我们谈论累积总计,只要你有一组交易,并且你有兴趣在某个序列(通常是时间)上累积它们的值。例如,您可能想要计算一个产品在所有时间内的总销售额,作为累计总额,或者您拥有的不同客户的总数,再次作为累计值。
让我们首先分析一个简单的数据透视表,显示了一段时间内销售的产品数量。您可以在图6-4中看到它。
所示的度量值非常简单:
[NumOfProducts] := SUM ( Sales[Quantity] )
您知道,对于当前过滤器上下文中可见的所有行,该度量对Sales中的Quantity列求和。例如,如果您采用2007年5月的值,那么筛选上下文在年份(2007)上有一个筛选器,在月份中有一个筛选器(2007年5月)。筛选器适用于Date,它与Sales具有一对多的关系。因此,它筛选Sales,仅显示2007年5月的销售额。
如果要计算累计总数,则必须找到一个新的筛选上下文,而不是仅筛选2007年5月,而是筛选2007年5月底之前的所有时段。尽管这句话很简单,但它隐藏了更大的复杂性。事实上,你需要做的是:
- 确定当前可见日期的末尾,如示例中所示,在2007年5月底。
- 使用该值创建一个筛选器,显示2007年5月底之前的所有日期。
隐藏的复杂性在于新的筛选上下文取决于当前的筛选上下文。此外,值得注意的是,新的筛选上下文将大于原始筛选上下文,因为它将包含2007年及更早的所有日期。在图6-5中,您可以看到为了计算累计销售的产品数量,您需要检索的日期集。
考虑到这个算法,现在是时候看看解决这个场景的公式了:
[CumulativeProducts] :=
CALCULATE (
SUM ( Sales[Quantity] ),
FILTER (
ALL ( 'Date' ),
'Date'[Datekey] <= MAX ( 'Date'[Datekey] )
))
公式的核心是使用CALCULATE设置的新筛选器,以粗体突出显示。您必须将注意力集中在关于此表达式的两个要点上:
- 使用ALL(Date)来忽略当前上下文。实际上,FILTER遍历整个表,分析当前筛选上下文之外的日期。这样,它将返回低于或等于当前筛选器的日期(在我们的示例中,仅包含2007年5月)。这也使得计算可以与Date表的任何其他列一起使用,例如Weeks或Quarters。
- 将Date[DateKey]与MAX (Date[DateKey])进行比较。当您不熟悉DAX时,这个表达式看起来很奇怪。
但是,如果你回想一下MAX的确切含义,你会发现它意味着“当前上下文中DateKey的最大值。”因为表达式是CALCULATE筛选器的一部分,所以它仍然在原始筛选上下文中工作(即,2007年5月)。因此,最大日期将是2007年5月的最后一天。另一方面,表达式Date [DateKey]是一个列名,意思是“当前行上下文中DateKey的值。”由于当前行上下文是FILTER在其迭代过程中创建的,因此表达式的读作:“筛选所有低于或等于2007年5月最后一天的日期。”
您可以在图6-6中看到该公式。
虽然这个公式计算的值是正确的,但随着时间的推移,存在一个问题。实际上,它显示了2010年及以后的数据,即使我们的数据库中没有2010年的数据。通常,它将显示未来期间的数据。它的行为是正确的,因为根据目前的知识,未来销售的产品累计数量是迄今为止的销售总数。不过,您可能不想显示这些数字。
您可以通过替换为空白来删除不需要的行。事实上,正如您可能已经知道的那样,默认情况下,数据透视表会隐藏所有值都为空的行(同样适用于列)。要隐藏行,您只需使用IF函数检查当前上下文中是否有销售,如下面的代码所示:
[CumulativeProducts] :=
IF (
COUNTROWS ( Sales ) > 0,
CALCULATE (
SUM ( Sales[Quantity] ),
FILTER (
ALL ( 'Date' ),
'Date'[Datekey] <= MAX ( 'Date'[Datekey] )
)))
在这个度量值中,我们依赖于这样一个事::如果缺少else分支,那么它将返回一个空白。你将在第七章“时间智能函数”中学习更多类似的公式。我们的目标不是学习模式,而是让您熟悉在不同筛选上下文中计算值的想法。
6.3 使用ABC(帕累托)分类
计算列存储在数据库中。这是一个简单的事实,你在本书的第一章中学到了,在这一点上,您不应该再感到惊讶。也就是说,这个简单的事实开辟了数据建模的新方法,在本节中,您将看到一个可以使用计算列非常有效地解决的场景。
作为使用计算列的示例,您将学习如何使用DAX解决ABC分析的场景。 ABC分析是帕累托原理的一种变体,它也被称为ABC / 帕累托分析。这是一个非常常见的技术来确定一个公司的核心业务,根据最好的产品或最好的客户。在这个例子中,我们专注于产品。
ABC分析的目标是为每个产品分配一个类别(A、B或C),其中:
- A类产品占收入的70%。
- B类产品占收入的20%。
- C类产品占收入的10%。
ABC分析的目标是确定哪些产品对整体业务有重大影响,以便管理人员可以把精力集中在它们身上。您可以在http://en.wikipedia.org/wiki/ABC_analysis上找到有关ABC分析的更多信息。
产品的ABC类需要存储在物理列中,因为您希望使用它来对按类分类信息的产品执行分析。例如,在图6-7中,您可以看到一个简单的数据透视表,它在行上使用ABC类。
A类产品为核心业务。B类产品不那么重要,但对公司来说仍然至关重要,而C类产品则是很好的淘汰对象,因为C类产品数量众多,与核心产品相比,营收微不足道。
ABC的定义很简单。但是,执行需要作出更多的努力。直观地说,您可以通过根据总利润对产品进行排序来计算ABC类,然后根据它们在排名中的位置来分配类。问题是DAX中缺少排序顺序的概念。因此,您需要找到一种不依赖排序的方法来确定产品的排名位置。让我们一步一步来。我们需要的第一个值是每种产品的总利润。您可以非常轻松地将其计算为Product表中的计算列:
Product[TotalProfit] =
CALCULATE (
SUMX (
Sales,
Sales[Quantity] * ( Sales[Unit Price] - Sales[Unit Cost] )
))
这将在Product中创建一个新的计算列,其中包含每行的总利润。正如我们之前所说,没有办法按此列对表进行物理排序。 DAX要求您以稍微不同的方式思考:您需要根据集合和筛选器来思考,而不是排序。例如,如果产品属于A类,则意味着它贡献了前70%的销售额。其思想是计算每种产品的累计总销售额以及所有销售额较高的产品的总销售额。
因此,最高产品的累计总计将仅包括其自身的销售额。 第二高的产品累计总计将是顶级产品加上自己的销售额之和。这适用于所有产品。计算每个产品的累计总销售额后,很容易将其转换为ABC类。实际上,如果累计销售额与总销售额之间的比率小于70%,那么这意味着该产品属于A类。另一方面,如果它低于90%,那么它就属于B类,否则,如果该比率高于90%,则产品属于C类。
这部分是ABC计算中唯一复杂的部分。它之所以难并不是因为DAX代码很复杂,而是因为它迫使您以一种不同的方式思考。让我们回顾一下增量利润计算列的代码
Product[IncrementalProfit] =
VAR
CurrentProfit = Product[TotalProfit]
RETURN
SUMX (
FILTER (
Product,
Product[TotalProfit] >= CurrentProfit
),
Product[TotalProfit]
)
此计算列基于前一个TotalProfit。内部筛选器检索总利润高于或等于当前利润的产品。换句话说,它检索比当前产品(包括当前产品)利润更高的产品。一旦FILTER返回此产品列表,外部SUMX将汇总其总利润。
使用EARLIER而不是变量
您可以使用EARLIER函数在不使用变量的情况下编写上一个公式,如下面的代码所示:
Product[IncrementalProfit] =
SUMX (
FILTER (
Product,
Product[TotalProfit] >= EARLIER (Product[TotalProfit] )
),
Product[TotalProfit]
)
通常,我们更喜欢尽可能使用变量,因为代码更易读并且易于维护。但是,如果使用不支持变量的DAX版本,则必须使用EARLIER版本。
您可以在图6-8中看到IncrementalProfit的行为。
此时,将累计利润转换为ABC类是很简单的。首先,您必须通过简单的除法将增量利润转换为总利润的百分比:
Product[IncrementalPct] =
DIVIDE (
Product[IncrementalProfit],
SUM ( Product[TotalProfit] )
)
因为分母处的SUM不在任何CALCULATE之内,所以这个总和是利润的总和,而IncrementalProfit是DAX计算公式的行的运行总和。 IncrementalPct列表示该产品以及所有利润更高的产品产生的利润百分比。您可以在图6-9中看到新列。
阅读表格,您可以看到最赚钱的产品占总利润的3.99%。前两个产品合计为6.52%,依此类推。
最后一步是将这个百分比转换为ABC段,你可以用一个非常简单的IF语句来完成:
Product[ABC Class] =
IF (
Product[IncrementalPct] <= 0.7,
"A",
IF (
Product[IncrementalPct] <= 0.9,
"B",
"C"
))
因为ABC Class是计算列,所以它存储在数据库中,您可以在切片器、筛选器、行或列上使用它来生成报告。
如果您有兴趣使用不同的度量值计算ABC类,例如使用特定年份的利润,那么您只需要修改计算TotalProfit列的代码,例如,使用不同的筛选器:
Product[TotalProfit] =
CALCULATE (
SUMX (
Sales,
Sales[Quantity] * ( Sales[Unit Price] - Sales[Unit Cost] )
),
Date[Calendar Year] = "CY2008"
)
此处值得一提的是ABC分析的其他变体,主要用于教育目的。实际上,为所有产品计算ABC类有点容易,但简单的变化通常隐藏了很多复杂性。
例如,您可能想要计算产品类别的ABC类。到目前为止,我们计算的分段将考虑分类中的所有产品的ABC类分配给每个产品。但是,如果放置一个类别切片器,则将使用全局ABC类检索该类别的产品。按类别计算类意味着为每个产品分配其类,仅计算该类别的值。
因此,您将拥有手机的ABC分类,电视机的ABC分类等等。通过这种分析,您可以专注于特定类别的最重要产品。我们将看到此分析的两种变体。第一个更简单,使用Product表Category中的非规范化列,您可以使用以下代码定义:
Product[Category] =
RELATED ( 'ProductCategory'[Category] )
TotalProfit列的定义与之前相同,因为它计算特定产品的销售额。差异始于IncrementalProfit的计算。实际上,要计算IncrementalProfit列,只考虑与当前类别相同的产品。而且,我们现在可以大胆地编写一些更优雅的DAX,而不是像在前面的示例中那样使用SUMX和FILTER:
Product[IncrementalProfit] =
CALCULATE (
SUM ( Product[TotalProfit] ),
ALLEXCEPT ( Product, 'Product'[Category] ),
Product[TotalProfit] >= EARLIER ( Product[TotalProfit] )
)
尽管这个表达式很短,但它隐藏了很多复杂性,值得解释一下。
- 外部CALCULATE将执行上下文转换。其内部表达式(总利润之和)应仅计算当前行的总利润值。但是,上下文转换不优先于CALCULATE的其他筛选器。因此,附加筛选器的目标是创建筛选上下文,使内部SUM仅计算当前类别中的累计利润。
- ALLEXCEPT从表中删除所有筛选器,但参数中指定的列除外。因为我们在Product上使用ALLEXCEPT,除产品类别外,这意味着将删除来自上下文转换的所有筛选器,仅保留ProductCategory上的筛选器。换句话说,我们要求计算与当前产品具有相同类别的所有产品的总利润之和。
- 第二个条件是与第一个条件在AND中,筛选总利润大于或等于当前总利润的产品,使用EARLIER计算。这看起来很奇怪,因为您可能还记得,只要您有嵌套的行上下文,EARLIER就很有用。在此公式中,除了由计算列定义创建的上下文之外,似乎没有其他行上下文。
但是,您可能还记得条件:
Product[TotalProfit] >= EARLIER ( Product[TotalProfit] )
对应于此表的表达式:
FILTER (
ALL ( Product[TotalProfit] ),
Product[TotalProfit] >= EARLIER ( Product[TotalProfit] )
)
在扩展版本中,现在很明显,Product上有两个嵌套的行上下文:一个由FILTER创建,另一个由计算列定义创建,因此需要使用EARLIER。
这个公式可能不是您想要解决问题的第一个公式。我们决定使用这个公式,因为它显示了一些非常常见和优雅的模式来描述您想要计算值的数据集。一旦您习惯了这种表达公式的方式,IncrementalPct定义应该看起来很简单:
Product[IncrementalPct] =
DIVIDE (
Product[IncrementalProfit],
CALCULATE (
SUM ( Product[TotalProfit] ),
ALLEXCEPT ( Product, Product[Category] )
))
为了将累计利润转换为百分比,我们再次使用ALLEXCEPT来计算当前类别的产品的总利润总和作为分母。花点时间仔细阅读并理解这些公式;它们有助于您的思维适应DAX代码的外观和工作方式。
在图6-10中,您可以在Product表中看到这些计算列。
您可以看到,现在,累计百分比不会像全局ABC类中那样线性增长。例如,第一个产品占电视和视频的利润的27.67%,而第二个产品占摄像机和摄像机的利润的11.58%。
您可以轻松地使用此ABC类为特定类别显示类中的分段和特定产品所属的类,如图6-11所示。
如果您的数据模型更加规范化,也就是说,Product中没有包含该类别的列,那么公式往往会更复杂一些。原因是您不能再仅筛选产品。相反,您需要在相关表上放置(或删除)筛选器。与之前的模型一样,只需要调整的两列是IncrementalProfit和IncrementalPct,您可以在此处看到:
Product[IncrementalProfit] =
CALCULATE (
SUM ( Product[TotalProfit] ),
FILTER (
Product,
RELATED ( 'Product Category'[ProductCategoryKey] ) =
EARLIER ( RELATED ( 'Product Category'[ProductCategoryKey] ) )
),
Product[TotalProfit] >= EARLIER ( Product[TotalProfit] )
)
Product[IncrementalPct] =
DIVIDE (
Product[IncrementalProfit],
CALCULATE (
SUM ( Product[TotalProfit] ),
FILTER (
Product,
RELATED ( 'Product Category'[ProductCategoryKey] ) =
EARLIER ( RELATED ( 'Product Category'[ProductCategoryKey] ) )
)))
我们突出显示了FILTER函数,该函数检索与当前类别相同的产品。值得注意的是使用EARLIER(RELATED()),它使用从上一行上下文中的当前行开始的关系来检索产品类别键。
让我们简要回顾一下你用ABC分类示例学到的东西:
- 使用计算列作为建模工具。实际上,您可以将非常复杂的逻辑放入计算列中,这些列在处理时计算,可以执行复杂计算而不会影响模型的速度。
- 因为您既不能对数据进行排序,也不能检索上一行的值(例如,在Excel中),因此需要使用FILTER定义的集合来定义要使用的集合。
- 除了某些列之外,还使用ALLEXCEPT从表中删除所有筛选器。
- 只要您想根据同一个表的列的当前值筛选一个表,EARLIER就非常有用。此外,您还可以使用EARLIER(RELATED())从相关表中获取值,使用上一行上下文。
ABC是一个有趣的例子,说明如何使用计算列生成分段。我们建议您深入学习这个示例,因为它包含许多有趣的细节,这些细节在本书的后面和您的日常DAX创作中是十分有用的。
6.4 计算每天和工作日的销售额
另一个有趣的例子是如何在DAX中计算值,这分析模型的设计中经常出现,是指在工作日内进行计算。在下一章中,您将学习有关时间智能计算的许多细节;在这里,我们希望关注一个更简单的场景。
您希望生成如图6-12所示的报告。
显示的数字具有不同的含义:
- Sales Amount是该期间的总销售额。
- DailySales是按天销售的平均金额。
- WorkDailySales是按工作日销售的平均金额,它计入非工作日,预计不会盈利。
以下是这些度量值的定义:
[NumOfDays] :=
COUNTROWS ( Date )
[NumOfWorkingDays] :=
CALCULATE ( [NumOfDays], Date[WorkingDay] = "WorkDay" )
[Sales Amount] :=
SUMX ( Sales, Sales[Quantity] * Sales[Unit Price] )
[DailySales] :=
DIVIDE ( [Sales Amount], [NumOfDays] )
[WorkDailySales] :=
DIVIDE ( [Sales Amount], [NumOfWorkingDays] )
这些度量值非常简单,但它们隐藏了我们必须解决的一些问题。实际上,在上一张图中,我们只筛选了两年:2007年和2008年。这些数字非常合理。如果我们删除筛选器,以便显示所有年份,问题就会变得明显,如图6-13所示。
如果您查看DailySales和WorkDailySales的总计,你马上就会发现这些数字是错误的。实际上,它们不能是行中所示的平均值。这个问题应该很容易理解,因为在数据透视表中我们显示的是天数和工作日数。在总计中,我们计算没有销售的日期,并且由于天数在分母处,因此最终值低于预期。
在这个场景中,我们不立即向您展示正确的公式,而是更愿意向您展示几个试验,以便有机会查看选择错误的解决方案对问题的影响。当然,最后,我们将提供正确的公式。
由于这两个度量值有相同的问题,让我们关注DailySales,当我们修正了这个公式后,我们将对NumOfWorkingDays进行相同的更新。
第一次试验可以清除没有销售的天数。因此,您可以尝试NumOfDays的这个新公式:
[NumOfDays] :=
IF (
[Sales Amount] > 0,
COUNTROWS ( Date )
)
此更新将使数据透视表看起来更好,因为没有销售的行将从中消失,但它不能修复总计。因此,它不仅错误,而且具有误导性,因为它使问题不那么明显,如图6-14所示。
正如您所看到的,即使所有没有销售的行从数据透视表中消失,总计仍然是错误的。它们消失了,因为我们将NumOfDays设置为Blank,因此数据透视表隐藏了行。然而,在总计中,销售额大于零,因此我们的公式仍然计算所有天数。第一次试验显然走错了方向。
这个度量值面临着一个有着明确模式的问题:我们称之为“粒度不匹配”。如果你仔细看看这些数字,它们在月份和年份级别上是正确的,只是在总计上错了。原因是,有些年头,没有销售。我们不想算那些年。因此,我们说在年度粒度上数字是正确的,而在总体粒度上数字是错误的。这里的问题是我们需要以正确的粒度计算公式,然后将结果合并到一个值中。您不能在更高的粒度计算此度量值。
这引发了一个问题:什么是正确的粒度?它可能是年、月、日。这一切都取决于业务规则,您需要在业务规则中定义它,以便设置正确的期望值。虽然我们正在查看总计中的错误,但我们仍然对年度值感到满意,但我们可以使用以下新公式设置年度级别的粒度:
[DailySales] :=
DIVIDE (
[Sales Amount],
SUMX (
VALUES ( 'Date'[Calendar Year] ),
IF (
[Sales Amount] > 0,
[NumOfDays]
)))
您可以看到,分母现在逐年迭代,对于每一年,它都会检查特定年份是否有销售。如果存在销售,则IF函数返回天数,否则它返回一个BLANK,并且不影响分母的总数。
备注:
值得记住的是,两个度量值Sales Amount和NumOfDays正常工作,因为它们周围有一个CALCULATE,由DAX自动插入。如果那个CALCULATE不存在,公式将无法正常工作。您可以使用度量值定义中相应的代码替换前面代码中的Sales Amount和NumOfDays,从而轻松地检查它。您将看到错误的结果,因为将丢失CALCULATE和相关的上下文转换。
在图6-15中,您可以看到结果现在是正确的,即使在总计中也是正确的。
这个度量值还没有结束。实际上,在我们用于这些测试的数据库中,所有年份都已完成,这意味着从1月1日到12月31日都有数据。在现实世界中,您可能会生成多年的报告,而这些报告并没有完全填满数据。例如,当您在8月份生成报告时,您将拥有截至8月份的数据,并且没有关于未来月份的信息。
在这种情况下,该度量值仍将报告错误的数字。在图6-16的报告中,我们取消了在2009年8月8日之后销售。
您可以看到8月是最后一个月(顺便说一下,它是不完整的),高于它的所有总数(即季度,年份和总计)都报告错误的数字。这种行为的原因是我们(故意)在我们的代码中使用了错误的粒度。正确的粒度不是年份,这是月份。我们使用年,因为它在年级别看来是正确的,但是,即使我们修正了总额的公式,我们仍然在年级别遇到同样的问题,只要一年中少了几个月。
因此,该度量值的正确表述需要考虑年份和月份,如:
[DailySales] :=
DIVIDE (
[Sales Amount],
SUMX (
VALUES ( Date[Calendar Year] ),
SUMX (
VALUES ( Date[Month] ),
IF (
[Sales Amount] > 0,
[NumOfDays]
))))
现在,分母对年和月执行两个嵌套循环,并检查每对(年、月),如果该月有销售,则正确地为只有销售的月份累积天数。您可以在图6-17中看到结果。
对于相同的表达式,一个更优雅的表达式可以是下面这个,它使用CROSSJOIN(您将在第9章“高级表函数”中学习CROSSJOIN)。
[DailySales] :=
DIVIDE (
[Sales Amount],
SUMX (
CROSSJOIN (
VALUES ( Date[Calendar Year] ),
VALUES ( Date[Month] )
),
IF (
[Sales Amount] > 0,
[NumOfDays]
)))
粒度不匹配问题在模型设计中经常出现。有些度量值只能在定义的粒度上计算,如果是这种情况,则需要迭代定义粒度的列,最后聚合部分结果。我们建议您详细研究此示例,因为它可以用在许多数据模型中。
值得注意的是,到上个月还存在一个小问题。事实上,NumOfDays报告的天数没有考虑到月份可能不完整。事实上,如果您在8月15日制作一份报告,则不应计入将来的日期,因为显然没有销售。如果您还希望在上个月生成正确的结果,则应通过删除最后一次销售之后的日期来进一步限制日期表,如下例所示:
[DailySalesCorrected] :=
CALCULATE (
DIVIDE (
[Sales Amount],
SUMX (
CROSSJOIN (
VALUES ( Date[Calendar Year] ),
VALUES ( Date[Month] )
),
IF ( [Sales Amount] > 0, [NumOfDays] )
) ),
FILTER ( 'Date', 'Date'[Date] <= MAX ( Sales[OrderDate] ) )
)
6.5 计算工作日的差异
我们要展示的下一个示例非常简单,但它非常有趣,因为它显示了一种使用DAX计算值的方法,这在其他分析引擎中并不常见,例如Analysis Services Multidimensional。
在事实表中,对于每个订单,您有两个日期:
- OrderDate是下订单的日期。
- ShipDate是订单发货的日期。
您可能有兴趣计算处理订单所需的天数,这很容易做到,这要归功于DAX存储日期的方式。实际上,只需执行一个简单的减法ShipDate - OrderDate即可计算出天数。话虽如此,计算得更有趣的数字是两个日期之间的工作日数,也就是说,在假期或非工作日,公司不工作时。因此,使用工作日而不是非工作日来计算处理订单的时间要更加公平。
事实证明,这比简单的减法更具挑战性。与前面的示例一样,日历表包含一个列,指示特定日期是否为工作日。您需要找到一种方法来使用该列来计算两个日期之间的差值,以工作日表示。
在数据模型中,Calendar表可用于按订单日期、年份等对订单进行切片。Calendar和事实表之间的关系基于订单日期。为了解决这种情况,您需要停止将Calendar表视为维度(仅用于筛选数据),并认为Calendar表只是一个表。你可以用不同的方法来计算数字,而不是仅仅用一个维度。
例如,您可以计算Calendar表中OrderDate和ShipDate之间的行数,同时也是工作日。如果以这种方式表达算法,它会使用计算列非常快速地转换为DAX:
Sales[WorkinDaysHandling] =
COUNTROWS (
FILTER (
Date,
AND (
AND (
Date[Date] >= Sales[OrderDate],
Date[Date] <= Sales[ShipDate]
),
Date[Working Day] = "WorkDay"
)))
表达更优雅的相同算法的一种方法如下:
Sales[WorkinDaysHandling] =
CALCULATE (
COUNTROWS ( Date ),
ALL ( Date ),
DATESBETWEEN ( Date[Date], Sales[OrderDate], Sales[ShipDate]
),
Date[WorkingDay] = "WorkDay"
)
后一种公式使用了DATESBETWEEN函数,您将在下一章中学习。到目前为止,这样说已经足够了,函数的作用是:返回一个表,其中包含作为参数传递的边界之间的所有日期。结果是一个表,您可以将其用作CALCULATE的筛选器,以随时间更改现有选择。
6.6 计算静态移动平均线
移动平均线是另一种常见的模式,您可以使用DAX以多种方式解决。在本节中,我们将提供一个如何以静态方式计算移动平均值的示例。例如,您使用包含某些股票价格的股票数据库。使用此数据模型,您需要计算过去50和200个期间的平均价格。在股票市场中,一种常见的交易技术是观察快速移动平均线(超过50个周期)何时越过较慢的平均值(200个周期)以确定买入和卖出点。
这个例子还要求,由于假期和数据库中的漏洞(就像股票价格未确定的日子一样),50个周期不对应于固定的天数。您希望考虑到数据库中的漏洞,并始终确保平均覆盖50个点,其中股票有一个确定的价格。
本例使用的数据模型非常简单,只包含一个日历表和另一个持有股票收盘价的表,如图6-18所示,其中1月6日至7日没有价格。
为了计算平均值,您可以使用计算列。主要原因是您希望在图表上显示此平均值,这意味着DAX将需要计算数百个值来绘制线条。由于许多点的平均值可能是一个缓慢的操作,因此您需要在计算列中合并计算,以生成更快的图表。
您需要计算用作移动平均线边界的日期,这些日期可能因股票而异。解决这个问题的第一步是为表中的每一行分配一个数字,该数字对于每只股票都是单调递增的。因此,您将数字1分配给Microsoft的第一个价格,并为Apple执行相同的操作。数字2将用于第二个价格,依此类推。结果如图6-19所示。
要计算此数字,只需对每一行计算与当前行的股票相同且日期较早的行数:
Prices[DayNumber] =
COUNTROWS (
FILTER (
Prices,
AND (
Prices[Date] <= EARLIER ( Prices[Date] ),
Prices[Stock] = EARLIER ( Prices[Stock] )
)))
解决方案的第二步是计算移动平均线。现在每行都有一个索引,很容易确定50或200个周期的边界。实际上,只需获取当前的DayNumber,减去要考虑的周期数,然后取出确定行的日期即可。
您可以使用两种不同的技术来计算列。第第一个解决方案更容易理解,即使代码不是最优的,也不如第二个解决方案优雅。尽管如此,它最大的优点是它以一种非常简单的方式遵循了前面概述的算法:
Prices[MovingAverage200] =
CALCULATE (
AVERAGE ( Prices[Close] ),
FILTER (
ALL ( Prices[Date] ),
AND (
Prices[Date]
>= LOOKUPVALUE (
Prices[Date],
Prices[Stock], EARLIER ( Prices[Stock] ),
Prices[DayNumber], EARLIER ( Prices[DayNumber] ) – 200
),
Prices[Date] <= EARLIER ( Prices[Date] )
) ),
ALLEXCEPT ( Prices, Prices[Stock] )
)
如您所见,公式的核心位于最内部的条件中,该条件筛选当前日期和第200天之间的日期。外部ALLEXCEPT需要将计算限制为仅相同股票的行。
该公式的第二个版本是一个更好的解决方案:
Prices[MovingAverage200] =
CALCULATE (
CALCULATE (
AVERAGE ( Prices[Close] ),
Prices[DayNumber] >= VALUES ( Prices[DayNumber] ) - 200,
Prices[DayNumber] <= VALUES ( Prices[DayNumber] )
),
ALLEXCEPT ( Prices, Prices[Stock], Prices[DayNumber] )
)
此版本的移动平均线使用两个嵌套的CALCULATE。外部CALCULATE使用ALLEXCEPT来确定筛选上下文中的股票和日期编号。设置这两列后,您可以安全地使用VALUES来检索它们的值,因为您知道由于上下文转换,两者都会有一个值。内部CALCULATE使用新筛选器替换DayNumber上的现有筛选器(由外部CALCULATE设置),该筛选器显示DayNumber的最后200个值。因此,外部CALCULATE在DayNumber上设置一个筛选器,内部CALCULATE立即删除。然而,Stock筛选器在外部CALCULATE中设置并且永远不会被删除,因此当DAX计算平均值时它仍然有效。
在模型中设置MovingAverage200和MovingAverage50的计算列后,您可以轻松地根据它们定义度量值:
[AVG 200] := AVERAGE ( Prices[MovingAverage200] )
[AVG 50] := AVERAGE ( Prices[MovingAverage50] )
[AVG Close] := AVERAGE ( Prices[Close] )
最后,您可以使用这些度量值来显示一个图表,该图表显示每个股票在移动平均线交叉处的买入和卖出点,如图6-20所示。